directus extension for ai-summarized wins

This commit is contained in:
Will Freeman
2026-02-26 15:45:53 -07:00
parent 33c5944466
commit ebdfb5a726
8 changed files with 6457 additions and 2 deletions

View File

@@ -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
View File

@@ -0,0 +1,2 @@
database/
uploads/

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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 (112)>,
"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 — 12 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));
});
};

View 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,
});

View 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>