mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-03-31 00:21:20 +02:00
directus extension for ai-summarized wins
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
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
|
||||
2
cms/.gitignore
vendored
Normal file
2
cms/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
database/
|
||||
uploads/
|
||||
@@ -16,4 +16,5 @@ services:
|
||||
CORS_ORIGIN: true
|
||||
EXTENSIONS_AUTO_RELOAD: true
|
||||
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
cms/extensions/import-win/package-lock.json
generated
Normal file
6205
cms/extensions/import-win/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
cms/extensions/import-win/package.json
Normal file
49
cms/extensions/import-win/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
101
cms/extensions/import-win/src/api/index.js
Normal file
101
cms/extensions/import-win/src/api/index.js
Normal file
@@ -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));
|
||||
});
|
||||
};
|
||||
16
cms/extensions/import-win/src/interface/index.js
Normal file
16
cms/extensions/import-win/src/interface/index.js
Normal file
@@ -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,
|
||||
});
|
||||
80
cms/extensions/import-win/src/interface/index.vue
Normal file
80
cms/extensions/import-win/src/interface/index.vue
Normal file
@@ -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