v1.0.110: prompts versioning — DB schema + API + UI (extract/diff tabs, history, activate)

This commit is contained in:
2026-06-23 01:06:12 +04:00
parent ded5071dae
commit 69cfb3457b
2 changed files with 382 additions and 57 deletions
+232 -26
View File
@@ -6,48 +6,254 @@
<!--- Создать таблицу при первом обращении --->
<cftry>
<cfquery datasource="baza">
DROP TABLE IF EXISTS prompts CASCADE
</cfquery>
<cfquery datasource="baza">
CREATE TABLE IF NOT EXISTS prompts (
contract_id UUID PRIMARY KEY REFERENCES contracts(id),
prompt_text TEXT NOT NULL
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
body TEXT NOT NULL,
parent_id UUID REFERENCES prompts(id),
is_active BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
created_by VARCHAR(50) DEFAULT 'system',
notes TEXT
)
</cfquery>
<cfcatch></cfcatch>
</cftry>
<cfparam name="url.contract_id" default="">
<!--- Авто-сид: если таблица пуста — создать дефолтные промпты --->
<cftry>
<cfquery name="cntCheck" datasource="baza">SELECT COUNT(*) as cnt FROM prompts</cfquery>
<cfif cntCheck.cnt EQ 0>
<!--- Дефолтный промпт для extract (первый документ) --->
<cfquery datasource="baza">
INSERT INTO prompts (role, name, body, is_active, notes) VALUES (
'extract', 'default-v1',
'Ты — анализатор договоров облачного провайдера.
Ниже текст спецификации услуг из ПЕРВОГО документа (базовый договор).
Извлеки ВСЕ строки спецификации в JSON-массив.
Верни СТРОГО JSON без пояснений. Не используй markdown-блоки.
ФОРМАТ:
{
"mode": "partial",
"ops": [
{"action": "ADD", "new_row": {"name": "полное наименование", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": ""}
]
}
ПРАВИЛА:
1. Извлеки КАЖДУЮ строку таблицы спецификации как отдельную ADD-операцию.
2. Пропускай итоговые строки ("Итого...") и строки с подписями.
3. Если ячейка пустая — ставь null (не пиши 0).
4. price, qty, sum — ЧИСЛА, не строки.
ТЕКСТ ДОКУМЕНТА:
---
{doc_text}
---',
true, 'Авто-создан из llm_prompt.py _build_initial'
)
</cfquery>
<!--- Дефолтный промпт для diff (сравнение с текущей спецификацией) --->
<cfquery datasource="baza">
INSERT INTO prompts (role, name, body, is_active, notes) VALUES (
'diff', 'default-v1',
'Ты — анализатор допсоглашений к договорам облачного провайдера.
У тебя есть текущая спецификация услуг и текст нового допсоглашения (ДС).
Твоя задача — определить, какие изменения вносит ДС в текущую спецификацию.
Верни СТРОГО JSON без пояснений. Не используй markdown-блоки.
ФОРМАТ:
{
"mode": "partial" | "full_replace",
"ops": [
{"action": "ADD", "new_row": {"name": "...", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": "..."},
{"action": "UPDATE", "target_id": "rN", "new_values": {"price": число}, "comment": "..."},
{"action": "DELETE", "target_id": "rN", "comment": "..."},
{"action": "UNRESOLVED", "new_values": {"name": "...", "price": число, ...}, "reason": "почему не смог сопоставить"}
]
}
ПРАВИЛА:
1. mode = "full_replace" — если в тексте есть фразы: «в следующей редакции», «заменить приложение», «излагается в следующей редакции». При full_replace — опиши ВСЕ новые строки как ADD.
2. mode = "partial" — если ДС меняет только отдельные строки.
3. Для UPDATE/DELETE — укажи target_id (r1, r2...) из списка текущей спецификации. НЕ придумывай новые id.
4. new_values в UPDATE — только ИЗМЕНЁННЫЕ поля (не все).
5. Если не можешь однозначно сопоставить строку — action: UNRESOLVED с reason.
6. Пропускай итоговые строки и подписи.
7. price, qty, sum — ЧИСЛА (не строки).
ТЕКУЩАЯ СПЕЦИФИКАЦИЯ:
{spec_current}
ТЕКСТ ДОПСОГЛАШЕНИЯ:
---
{doc_text}
---',
true, 'Авто-создан из llm_prompt.py _build_diff'
)
</cfquery>
</cfif>
<cfcatch></cfcatch>
</cftry>
<cfparam name="url.action" default="get_active">
<cfset result = {ok: false, error: ""}>
<cftry>
<cfif NOT len(url.contract_id)>
<cfset result.error = "contract_id required">
<cfelseif cgi.request_method EQ "POST">
<!--- Сохранить промпт --->
<cfif url.action EQ "get_active" AND structKeyExists(url, "role")>
<!--- Получить активный промпт для роли --->
<cfquery name="q" datasource="baza">
SELECT id, name, body, created_at, notes
FROM prompts
WHERE role = <cfqueryparam value="#url.role#" cfsqltype="cf_sql_varchar">
AND is_active = true
ORDER BY created_at DESC
LIMIT 1
</cfquery>
<cfif q.recordCount GT 0>
<cfset result = {ok: true, id: q.id, name: q.name, body: q.body, created_at: q.created_at, notes: q.notes}>
<cfelse>
<cfset result = {ok: true, id: "", name: "", body: "", created_at: "", notes: ""}>
</cfif>
<cfelseif url.action EQ "list" AND structKeyExists(url, "role")>
<!--- Все версии для роли (история) --->
<cfquery name="q" datasource="baza">
SELECT id, name, is_active, created_at, created_by, notes
FROM prompts
WHERE role = <cfqueryparam value="#url.role#" cfsqltype="cf_sql_varchar">
ORDER BY created_at DESC
</cfquery>
<cfset versions = []>
<cfloop query="q">
<cfset arrayAppend(versions, {
id: q.id, name: q.name, is_active: q.is_active,
created_at: q.created_at, created_by: q.created_by, notes: q.notes
})>
</cfloop>
<cfset result = {ok: true, versions: versions}>
<cfelseif url.action EQ "get" AND structKeyExists(url, "id")>
<!--- Получить конкретную версию --->
<cfquery name="q" datasource="baza">
SELECT id, role, name, body, parent_id, is_active, created_at, created_by, notes
FROM prompts
WHERE id = <cfqueryparam value="#url.id#" cfsqltype="cf_sql_varchar">
</cfquery>
<cfif q.recordCount GT 0>
<cfset result = {ok: true, id: q.id, role: q.role, name: q.name, body: q.body,
parent_id: q.parent_id, is_active: q.is_active, created_at: q.created_at,
created_by: q.created_by, notes: q.notes}>
<cfelse>
<cfset result.error = "prompt not found">
</cfif>
<cfelseif url.action EQ "save" AND cgi.request_method EQ "POST">
<!--- Создать новую версию (старая остаётся в истории) --->
<cfset body = toString(getHttpRequestData().content)>
<cfset data = deserializeJSON(body)>
<cfset prompt = structKeyExists(data, "prompt") ? data.prompt : "">
<cfquery datasource="baza">
DELETE FROM prompts WHERE contract_id = <cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar">
</cfquery>
<cfif len(trim(prompt))>
<cfquery datasource="baza">
INSERT INTO prompts (contract_id, prompt_text) VALUES (
<cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar">,
<cfqueryparam value="#prompt#" cfsqltype="cf_sql_varchar">
)
</cfquery>
<cfset role = structKeyExists(data, "role") ? data.role : "diff">
<cfset pname = structKeyExists(data, "name") ? data.name : "unnamed">
<cfset pbody = structKeyExists(data, "body") ? data.body : "">
<cfset parentId = structKeyExists(data, "parent_id") AND len(data.parent_id) ? data.parent_id : "">
<cfset pnotes = structKeyExists(data, "notes") ? data.notes : "">
<cfset makeActive = structKeyExists(data, "is_active") ? data.is_active : true>
<cfif NOT len(trim(pbody))>
<cfset result.error = "body required">
<cfelse>
<cftransaction>
<!--- Если делаем активным — сбросить флаг у всех той же роли --->
<cfif makeActive>
<cfquery datasource="baza">
UPDATE prompts SET is_active = false
WHERE role = <cfqueryparam value="#role#" cfsqltype="cf_sql_varchar">
</cfquery>
</cfif>
<cfquery datasource="baza" result="ins">
INSERT INTO prompts (role, name, body, parent_id, is_active, created_by, notes)
VALUES (
<cfqueryparam value="#role#" cfsqltype="cf_sql_varchar">,
<cfqueryparam value="#pname#" cfsqltype="cf_sql_varchar">,
<cfqueryparam value="#pbody#" cfsqltype="cf_sql_varchar">,
<cfif len(parentId)><cfqueryparam value="#parentId#" cfsqltype="cf_sql_varchar"><cfelse>NULL</cfif>,
<cfqueryparam value="#makeActive#" cfsqltype="cf_sql_bit">,
<cfqueryparam value="user" cfsqltype="cf_sql_varchar">,
<cfif len(pnotes)><cfqueryparam value="#pnotes#" cfsqltype="cf_sql_varchar"><cfelse>NULL</cfif>
)
</cfquery>
</cftransaction>
<cfset result = {ok: true, id: ins.generatedKey}>
</cfif>
<cfset result = {ok: true, saved: len(trim(prompt)) GT 0}>
<cfelseif url.action EQ "activate" AND cgi.request_method EQ "POST">
<!--- Активировать конкретную версию --->
<cfset body = toString(getHttpRequestData().content)>
<cfset data = deserializeJSON(body)>
<cfset pid = structKeyExists(data, "id") ? data.id : "">
<cfif NOT len(pid)>
<cfset result.error = "id required">
<cfelse>
<cfquery name="chk" datasource="baza">
SELECT role FROM prompts WHERE id = <cfqueryparam value="#pid#" cfsqltype="cf_sql_varchar">
</cfquery>
<cfif chk.recordCount EQ 0>
<cfset result.error = "prompt not found">
<cfelse>
<cftransaction>
<cfquery datasource="baza">
UPDATE prompts SET is_active = false
WHERE role = <cfqueryparam value="#chk.role#" cfsqltype="cf_sql_varchar">
</cfquery>
<cfquery datasource="baza">
UPDATE prompts SET is_active = true
WHERE id = <cfqueryparam value="#pid#" cfsqltype="cf_sql_varchar">
</cfquery>
</cftransaction>
<cfset result = {ok: true}>
</cfif>
</cfif>
<cfelseif url.action EQ "delete" AND cgi.request_method EQ "POST">
<!--- Удалить неактивную версию (активную нельзя) --->
<cfset body = toString(getHttpRequestData().content)>
<cfset data = deserializeJSON(body)>
<cfset pid = structKeyExists(data, "id") ? data.id : "">
<cfif NOT len(pid)>
<cfset result.error = "id required">
<cfelse>
<cfquery name="chk" datasource="baza">
SELECT is_active FROM prompts WHERE id = <cfqueryparam value="#pid#" cfsqltype="cf_sql_varchar">
</cfquery>
<cfif chk.recordCount EQ 0>
<cfset result.error = "prompt not found">
<cfelseif chk.is_active>
<cfset result.error = "cannot delete active prompt">
<cfelse>
<cfquery datasource="baza">
DELETE FROM prompts WHERE id = <cfqueryparam value="#pid#" cfsqltype="cf_sql_varchar">
</cfquery>
<cfset result = {ok: true}>
</cfif>
</cfif>
<cfelse>
<!--- Получить промпт --->
<cfquery name="q" datasource="baza">
SELECT prompt_text FROM prompts
WHERE contract_id = <cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar">
</cfquery>
<cfset result = {ok: true, prompt: q.recordCount GT 0 ? q.prompt_text : ""}>
<cfset result.error = "unknown action (use: get_active, list, get, save, activate, delete)">
</cfif>
<cfcatch>
<cfset result = {ok: false, error: cfcatch.message}>
</cfcatch>