mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-08-15 15:10:28 +02:00
directus extension for ai-summarized wins
This commit is contained in:
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
CF_API_KEY=your_cloudflare_api_key
|
CF_API_KEY=your_cloudflare_api_key
|
||||||
CF_ZONE_ID=your_cloudflare_zone_id
|
CF_ZONE_ID=your_cloudflare_zone_id
|
||||||
|
OPENAI_API_KEY=your_openai_api_key
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
database/
|
||||||
|
uploads/
|
||||||
@@ -16,4 +16,5 @@ services:
|
|||||||
CORS_ORIGIN: true
|
CORS_ORIGIN: true
|
||||||
EXTENSIONS_AUTO_RELOAD: true
|
EXTENSIONS_AUTO_RELOAD: true
|
||||||
CF_API_KEY: ${CF_API_KEY}
|
CF_API_KEY: ${CF_API_KEY}
|
||||||
CF_ZONE_ID: ${CF_ZONE_ID}
|
CF_ZONE_ID: ${CF_ZONE_ID}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
+6205
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-import-win",
|
||||||
|
"description": "Directus extension to import a Flock Safety contract-loss win from a URL using OpenAI",
|
||||||
|
"icon": "file_download",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"keywords": [
|
||||||
|
"directus",
|
||||||
|
"directus-extension",
|
||||||
|
"directus-custom-bundle"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "bundle",
|
||||||
|
"path": {
|
||||||
|
"app": "dist/app.js",
|
||||||
|
"api": "dist/api.js"
|
||||||
|
},
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"type": "endpoint",
|
||||||
|
"name": "import-win",
|
||||||
|
"source": "src/api/index.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "interface",
|
||||||
|
"name": "win-importer",
|
||||||
|
"source": "src/interface/index.js"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"host": "^10.3.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build",
|
||||||
|
"dev": "directus-extension build -w --no-minify",
|
||||||
|
"link": "directus-extension link",
|
||||||
|
"add": "directus-extension add"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
|
"openai": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "10.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
const MODEL = 'gpt-4o-mini';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are a data extraction assistant. You will be given the text of a news article about a city terminating, rejecting, or deactivating an ALPR (Automatic License Plate Reader) contract or system — typically with a vendor like Flock Safety.
|
||||||
|
|
||||||
|
Extract the following fields and return ONLY valid JSON with no additional text:
|
||||||
|
|
||||||
|
{
|
||||||
|
"year": <integer — year the article was published>,
|
||||||
|
"month": <integer — month the article was published (1–12)>,
|
||||||
|
"city": <string — name of the city that is the primary subject>,
|
||||||
|
"state": <string — two-letter US state abbreviation>,
|
||||||
|
"outcome": <one of exactly: "Contract Canceled", "Contract Rejected", or "Cameras Deactivated">,
|
||||||
|
"description": <string — 1–2 sentence summary of the outcome, ending with an HTML anchor tag linking to the article>
|
||||||
|
}
|
||||||
|
|
||||||
|
Outcome definitions:
|
||||||
|
- "Contract Canceled": an existing contract was terminated before its natural end
|
||||||
|
- "Contract Rejected": a proposed contract was not accepted initially, or an existing contract was not renewed
|
||||||
|
- "Cameras Deactivated": cameras were turned off or removed for any other reason
|
||||||
|
|
||||||
|
The description must include an <a> tag wrapping the key verb phrase that describes what happened — such as "canceled their contract", "voted not to renew", "terminated the agreement", etc. Format the tag exactly as:
|
||||||
|
<a href="ARTICLE_URL" target="_blank">VERB PHRASE</a>
|
||||||
|
|
||||||
|
Replace ARTICLE_URL with the actual URL provided and VERB PHRASE with the natural language action from the sentence. Do not add a separate "Read more" link.`;
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'', 'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function fetchArticleText(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; import-win/1.0)' },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch article: ${response.status} ${response.statusText}`);
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = load(html);
|
||||||
|
$('script, style, nav, footer, header, aside').remove();
|
||||||
|
return $.text().replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractFields(openai, articleText, url) {
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: MODEL,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: `Article URL: ${url}\n\nArticle text:\n${articleText.slice(0, 12000)}` },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return JSON.parse(response.choices[0].message.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCmsPayload(result) {
|
||||||
|
return {
|
||||||
|
cityState: `${result.city}, ${result.state}`,
|
||||||
|
monthYear: `${MONTH_NAMES[result.month]} ${result.year}`,
|
||||||
|
description: result.description,
|
||||||
|
outcome: result.outcome,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (router, { env }) => {
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
if (!req.accountability?.user) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = req.body ?? {};
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Request body must include a "url" string.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = env['OPENAI_API_KEY'];
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(500).json({ error: 'OPENAI_API_KEY is not configured on the server.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let articleText;
|
||||||
|
try {
|
||||||
|
articleText = await fetchArticleText(url);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(422).json({ error: `Failed to fetch article: ${err.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const openai = new OpenAI({ apiKey });
|
||||||
|
|
||||||
|
let extracted;
|
||||||
|
try {
|
||||||
|
extracted = await extractFields(openai, articleText, url);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: `OpenAI extraction failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(toCmsPayload(extracted));
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineInterface } from '@directus/extensions-sdk';
|
||||||
|
import InterfaceComponent from './index.vue';
|
||||||
|
|
||||||
|
export default defineInterface({
|
||||||
|
id: 'win-importer',
|
||||||
|
name: 'Import Win',
|
||||||
|
icon: 'file_download',
|
||||||
|
description: 'Paste an article URL to auto-populate win fields via OpenAI.',
|
||||||
|
component: InterfaceComponent,
|
||||||
|
types: ['alias'],
|
||||||
|
localTypes: ['presentation'],
|
||||||
|
group: 'presentation',
|
||||||
|
options: null,
|
||||||
|
hideLabel: false,
|
||||||
|
hideLoader: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div class="import-win">
|
||||||
|
<v-input
|
||||||
|
v-model="articleUrl"
|
||||||
|
placeholder="https://example.com/article"
|
||||||
|
:disabled="loading"
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
<v-button
|
||||||
|
class="import-btn"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!articleUrl"
|
||||||
|
@click="importWin"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</v-button>
|
||||||
|
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
setFieldValue: {
|
||||||
|
type: Function,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const articleUrl = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
|
||||||
|
async function importWin() {
|
||||||
|
if (!articleUrl.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/import-win', { url: articleUrl.value });
|
||||||
|
|
||||||
|
if (props.setFieldValue) {
|
||||||
|
props.setFieldValue('cityState', data.cityState);
|
||||||
|
props.setFieldValue('monthYear', data.monthYear);
|
||||||
|
props.setFieldValue('description', data.description);
|
||||||
|
props.setFieldValue('outcome', data.outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
articleUrl.value = '';
|
||||||
|
} catch (err) {
|
||||||
|
const message = err?.response?.data?.error ?? err?.message ?? 'An unknown error occurred.';
|
||||||
|
errorMessage.value = `Import failed: ${message}`;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.import-win {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
width: 100%;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user