Add profile generate refine via manager hint and auto-preview.

When Preview leaves fields empty, managers can send a hint plus the current profile so DeepSeek revises rules; generate now returns preview and empty_fields for the UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-16 22:25:27 +03:00
co-authored by Cursor
parent 71f8cf169e
commit 52185aeaa8
4 changed files with 203 additions and 35 deletions
@@ -20,10 +20,14 @@ router = APIRouter(prefix="/admin/parser-profiles", tags=["parser-profiles"])
class GenerateRequest(BaseModel): class GenerateRequest(BaseModel):
sample_post: str = Field(min_length=1) sample_post: str = Field(min_length=1)
hint: str | None = None
current_profile: dict[str, Any] | None = None
class GenerateResponse(BaseModel): class GenerateResponse(BaseModel):
profile: dict[str, Any] profile: dict[str, Any]
preview: dict[str, str]
empty_fields: list[str] = Field(default_factory=list)
class PreviewRequest(BaseModel): class PreviewRequest(BaseModel):
@@ -65,7 +69,11 @@ async def generate_profile(payload: GenerateRequest):
detail="DEEPSEEK_API_KEY is not set on ca-api. Add it to .env for parser generation.", detail="DEEPSEEK_API_KEY is not set on ca-api. Add it to .env for parser generation.",
) )
try: try:
profile = await builder.generate_profile(payload.sample_post) profile = await builder.generate_profile(
payload.sample_post,
hint=payload.hint,
current_profile=payload.current_profile,
)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
@@ -73,7 +81,13 @@ async def generate_profile(payload: GenerateRequest):
status_code=502, status_code=502,
detail=f"DeepSeek generate failed: {exc}", detail=f"DeepSeek generate failed: {exc}",
) from exc ) from exc
return GenerateResponse(profile=profile.model_dump()) dumped = profile.model_dump()
preview = builder.preview_with_profile(payload.sample_post, dumped)
return GenerateResponse(
profile=dumped,
preview=preview,
empty_fields=builder.empty_preview_fields(preview),
)
@router.post("/preview", response_model=PreviewResponse) @router.post("/preview", response_model=PreviewResponse)
@@ -22,7 +22,8 @@ logger = logging.getLogger("ca.parser_builder")
GENERATE_SYSTEM = ( GENERATE_SYSTEM = (
"You design a static heuristic parser profile for structured Telegram posts. " "You design a static heuristic parser profile for structured Telegram posts. "
"Output valid JSON only, no markdown, no Python code. " "Output valid JSON only, no markdown, no Python code. "
"The profile is applied with regex/line/marker rules at runtime — never with an LLM." "The profile is applied with regex/line/marker rules at runtime — never with an LLM. "
"Every rule must actually extract a non-empty value from the given sample when applied."
) )
@@ -46,6 +47,10 @@ def preview_with_profile(sample_post: str, profile: dict[str, Any] | HeuristicPr
return apply_profile(sample_post, profile) return apply_profile(sample_post, profile)
def empty_preview_fields(preview: dict[str, str]) -> list[str]:
return [name for name, value in preview.items() if not (value or "").strip()]
def _extract_json_object(content: str) -> dict[str, Any]: def _extract_json_object(content: str) -> dict[str, Any]:
content = (content or "").strip() content = (content or "").strip()
if content.startswith("```"): if content.startswith("```"):
@@ -63,21 +68,11 @@ def _extract_json_object(content: str) -> dict[str, Any]:
return parsed return parsed
async def generate_profile(sample_post: str) -> HeuristicProfile: def _base_user_prompt(sample: str) -> str:
settings = deepseek_settings()
if not settings["api_key"]:
raise RuntimeError(
"DEEPSEEK_API_KEY is not set on ca-api. Add it to .env for parser generation."
)
sample = (sample_post or "").strip()
if not sample:
raise ValueError("sample_post is required")
fields_help = "\n".join( fields_help = "\n".join(
f"- {spec['name']}: {spec['description']}" for spec in target_field_specs() f"- {spec['name']}: {spec['description']}" for spec in target_field_specs()
) )
user_prompt = ( return (
"Build a HeuristicProfile JSON for this sample Telegram post.\n\n" "Build a HeuristicProfile JSON for this sample Telegram post.\n\n"
"Schema:\n" "Schema:\n"
'{"version": 1, "notes": "...", "fields": {' '{"version": 1, "notes": "...", "fields": {'
@@ -89,14 +84,88 @@ async def generate_profile(sample_post: str) -> HeuristicProfile:
"- Only include fields you can extract reliably from the sample.\n" "- Only include fields you can extract reliably from the sample.\n"
f"- Allowed field names: {', '.join(TARGET_FIELDS)}.\n" f"- Allowed field names: {', '.join(TARGET_FIELDS)}.\n"
"- Prefer regex / line / after_marker / between over literal.\n" "- Prefer regex / line / after_marker / between over literal.\n"
"- Use literal only for constant topic tags.\n" "- Use literal only for constant topic/region tags.\n"
"- title: usually the first non-empty line (strategy line, line_index 0).\n"
"- description: the multi-line body AFTER the title and BEFORE coords/hashtags. "
"Prefer strategy between or regex with flags including \"s\" (DOTALL). "
"Do NOT leave description empty if the sample has a body paragraph.\n"
"- locality: settlement name from the title line when present.\n"
"- coords should capture 'lat, lon' when present.\n" "- coords should capture 'lat, lon' when present.\n"
"- event_date should capture DD.MM.YYYY or similar.\n" "- event_date should capture DD.MM.YYYY / DD.MM.YY or similar.\n"
"- Do not invent Python code; only declarative rules.\n\n" "- topic/region: hashtags like #ru → topic \"ru\"; avoid stuffing unrelated tags into region.\n"
"- Do not invent Python code; only declarative rules.\n"
"- Mentally apply each rule to the sample: every included field must yield a non-empty string.\n\n"
f"Target fields:\n{fields_help}\n\n" f"Target fields:\n{fields_help}\n\n"
f"Sample post:\n{sample[:12000]}" f"Sample post:\n{sample[:12000]}"
) )
def _parse_profile_response(content: str) -> HeuristicProfile:
raw = _extract_json_object(content)
if "fields" not in raw and isinstance(raw.get("profile"), dict):
raw = raw["profile"]
if "version" not in raw:
raw["version"] = 1
return HeuristicProfile.model_validate(raw)
async def generate_profile(
sample_post: str,
*,
hint: str | None = None,
current_profile: dict[str, Any] | HeuristicProfile | None = None,
) -> HeuristicProfile:
settings = deepseek_settings()
if not settings["api_key"]:
raise RuntimeError(
"DEEPSEEK_API_KEY is not set on ca-api. Add it to .env for parser generation."
)
sample = (sample_post or "").strip()
if not sample:
raise ValueError("sample_post is required")
hint_text = (hint or "").strip()
current: HeuristicProfile | None = None
if current_profile is not None:
current = (
current_profile
if isinstance(current_profile, HeuristicProfile)
else HeuristicProfile.model_validate(current_profile)
)
if current is not None or hint_text:
preview = preview_with_profile(sample, current) if current is not None else {}
empty = empty_preview_fields(preview) if preview else []
parts = [
"Revise the HeuristicProfile JSON for this sample Telegram post.",
"Keep strategies that already work; fix only what the manager asks for "
"and any fields that preview as empty.",
"Output the full updated profile JSON (same schema), not a patch.",
"",
_base_user_prompt(sample),
]
if current is not None:
parts.extend(
[
"",
"Current profile JSON:",
json.dumps(current.model_dump(), ensure_ascii=False, indent=2)[:12000],
]
)
if empty:
parts.append(
"Fields currently empty in preview (must become non-empty if present in sample): "
+ ", ".join(empty)
)
else:
parts.append("Current preview fields: " + json.dumps(preview, ensure_ascii=False))
if hint_text:
parts.extend(["", "Manager hint (follow this):", hint_text[:4000]])
user_prompt = "\n".join(parts)
else:
user_prompt = _base_user_prompt(sample)
payload = { payload = {
"model": settings["model"], "model": settings["model"],
"messages": [ "messages": [
@@ -121,10 +190,4 @@ async def generate_profile(sample_post: str) -> HeuristicProfile:
data = response.json() data = response.json()
content = data["choices"][0]["message"]["content"] content = data["choices"][0]["message"]["content"]
raw = _extract_json_object(content) return _parse_profile_response(content)
# Accept either top-level profile or {"profile": {...}}
if "fields" not in raw and isinstance(raw.get("profile"), dict):
raw = raw["profile"]
if "version" not in raw:
raw["version"] = 1
return HeuristicProfile.model_validate(raw)
+22 -3
View File
@@ -64,10 +64,29 @@ export function fetchTargetFields(): Promise<{ fields: TargetField[] }> {
return request<{ fields: TargetField[] }>("/parser-profiles/target-fields", undefined, ADMIN_BASE); return request<{ fields: TargetField[] }>("/parser-profiles/target-fields", undefined, ADMIN_BASE);
} }
export function generateParserProfile(sample_post: string): Promise<{ profile: HeuristicProfile }> { export function generateParserProfile(payload: {
return request<{ profile: HeuristicProfile }>( sample_post: string;
hint?: string;
current_profile?: HeuristicProfile | Record<string, unknown> | null;
}): Promise<{
profile: HeuristicProfile;
preview: Record<string, string>;
empty_fields: string[];
}> {
return request<{
profile: HeuristicProfile;
preview: Record<string, string>;
empty_fields: string[];
}>(
"/parser-profiles/generate", "/parser-profiles/generate",
{ method: "POST", body: JSON.stringify({ sample_post }) }, {
method: "POST",
body: JSON.stringify({
sample_post: payload.sample_post,
hint: payload.hint || undefined,
current_profile: payload.current_profile || undefined,
}),
},
ADMIN_BASE, ADMIN_BASE,
); );
} }
@@ -20,10 +20,12 @@ const success = ref("");
const submitting = ref(false); const submitting = ref(false);
const samplePost = ref(""); const samplePost = ref("");
const generateHint = ref("");
const profileName = ref(""); const profileName = ref("");
const targetFields = ref<TargetField[]>([]); const targetFields = ref<TargetField[]>([]);
const profileJson = ref(""); const profileJson = ref("");
const previewFields = ref<Record<string, string> | null>(null); const previewFields = ref<Record<string, string> | null>(null);
const emptyFields = ref<string[]>([]);
const generating = ref(false); const generating = ref(false);
const previewing = ref(false); const previewing = ref(false);
const loadingFields = ref(true); const loadingFields = ref(true);
@@ -73,10 +75,32 @@ async function handleGenerate() {
error.value = ""; error.value = "";
success.value = ""; success.value = "";
previewFields.value = null; previewFields.value = null;
emptyFields.value = [];
try { try {
const data = await generateParserProfile(samplePost.value); let current: HeuristicProfile | null = null;
if (profileJson.value.trim()) {
current = syncProfileFromJson();
if (!current) return;
}
const data = await generateParserProfile({
sample_post: samplePost.value,
hint: generateHint.value.trim() || undefined,
current_profile: current,
});
profileJson.value = JSON.stringify(data.profile, null, 2); profileJson.value = JSON.stringify(data.profile, null, 2);
previewFields.value = data.preview;
emptyFields.value = data.empty_fields || [];
if (emptyFields.value.length) {
success.value =
`Профиль обновлён, но пустые поля: ${emptyFields.value.join(", ")}. ` +
"Допишите подсказку ниже и снова нажмите Generate.";
if (!generateHint.value.trim() && emptyFields.value.includes("description")) {
generateHint.value =
"description должен содержать многострочный текст тела поста после заголовка и до координат (47.…, 36.…), без строки заголовка и без хвоста Источник/Геопривязка/#теги";
}
} else {
success.value = "Профиль сгенерирован. Preview и runtime — только по правилам, без LLM."; success.value = "Профиль сгенерирован. Preview и runtime — только по правилам, без LLM.";
}
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : "Ошибка генерации"; error.value = err instanceof Error ? err.message : "Ошибка генерации";
} finally { } finally {
@@ -99,6 +123,9 @@ async function handlePreview() {
try { try {
const data = await previewParserProfile(samplePost.value, current); const data = await previewParserProfile(samplePost.value, current);
previewFields.value = data.fields; previewFields.value = data.fields;
emptyFields.value = Object.entries(data.fields)
.filter(([, v]) => !v)
.map(([k]) => k);
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : "Ошибка preview"; error.value = err instanceof Error ? err.message : "Ошибка preview";
} finally { } finally {
@@ -110,9 +137,11 @@ function resetEditor() {
editingId.value = null; editingId.value = null;
profileName.value = ""; profileName.value = "";
samplePost.value = ""; samplePost.value = "";
generateHint.value = "";
profileJson.value = ""; profileJson.value = "";
profileStatus.value = "draft"; profileStatus.value = "draft";
previewFields.value = null; previewFields.value = null;
emptyFields.value = [];
success.value = ""; success.value = "";
} }
@@ -120,11 +149,13 @@ function openEdit(p: ParserProfile) {
editingId.value = p.id; editingId.value = p.id;
profileName.value = p.name; profileName.value = p.name;
samplePost.value = p.sample_post || ""; samplePost.value = p.sample_post || "";
generateHint.value = "";
profileJson.value = p.heuristic_profile profileJson.value = p.heuristic_profile
? JSON.stringify(p.heuristic_profile, null, 2) ? JSON.stringify(p.heuristic_profile, null, 2)
: ""; : "";
profileStatus.value = p.status === "ready" ? "ready" : "draft"; profileStatus.value = p.status === "ready" ? "ready" : "draft";
previewFields.value = null; previewFields.value = null;
emptyFields.value = [];
success.value = ""; success.value = "";
error.value = ""; error.value = "";
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
@@ -197,8 +228,9 @@ onMounted(() => {
<div class="page"> <div class="page">
<h2 class="page-heading">Профили парсера</h2> <h2 class="page-heading">Профили парсера</h2>
<p class="intro"> <p class="intro">
Статичный эвристический профиль: Generate (DeepSeek один раз) → Preview → Сохранить. Generate строит статичные правила по образцу поста. Если Preview пустой по полю —
Дальше профиль подключается к каналу на странице напишите подсказку и снова Generate (агент правит текущий JSON). Можно править JSON
вручную. Дальше профиль подключается к каналу на
<router-link to="/parsers">Парсеры</router-link>. <router-link to="/parsers">Парсеры</router-link>.
</p> </p>
@@ -231,6 +263,16 @@ onMounted(() => {
placeholder="15.03.2024 Населённый пункт&#10;&#10;Описание…&#10;&#10;48.123456, 37.654321" placeholder="15.03.2024 Населённый пункт&#10;&#10;Описание…&#10;&#10;48.123456, 37.654321"
/> />
</label> </label>
<label>
Подсказка агенту
<span class="muted"> (если Generate ошибся — опишите, что исправить, и нажмите Generate снова)</span>
<textarea
v-model="generateHint"
class="hint-area"
rows="3"
placeholder="например: description — абзацы между заголовком и координатами, без #хештегов"
/>
</label>
<div class="form-actions"> <div class="form-actions">
<button <button
type="button" type="button"
@@ -238,7 +280,13 @@ onMounted(() => {
:disabled="generating || !samplePost.trim()" :disabled="generating || !samplePost.trim()"
@click="handleGenerate" @click="handleGenerate"
> >
{{ generating ? "Генерация..." : "Generate" }} {{
generating
? "Генерация..."
: generateHint.trim() || profileJson.trim()
? "Generate / Refine"
: "Generate"
}}
</button> </button>
<button <button
type="button" type="button"
@@ -297,6 +345,9 @@ onMounted(() => {
<section v-if="previewFields" class="card"> <section v-if="previewFields" class="card">
<h3>Preview строки</h3> <h3>Preview строки</h3>
<p v-if="emptyFields.length" class="form-warn">
Пустые поля: <code>{{ emptyFields.join(", ") }}</code> — уточните подсказку и Generate / Refine.
</p>
<div class="table-wrap"> <div class="table-wrap">
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
@@ -306,7 +357,12 @@ onMounted(() => {
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td v-for="(val, key) in previewFields" :key="key" class="preview-cell"> <td
v-for="(val, key) in previewFields"
:key="key"
class="preview-cell"
:class="{ empty: !val }"
>
{{ val || "—" }} {{ val || "—" }}
</td> </td>
</tr> </tr>
@@ -376,7 +432,8 @@ onMounted(() => {
} }
.sample-area, .sample-area,
.profile-area { .profile-area,
.hint-area {
width: 100%; width: 100%;
font: inherit; font: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
@@ -389,6 +446,10 @@ onMounted(() => {
margin-top: 0.35rem; margin-top: 0.35rem;
} }
.hint-area {
font-family: inherit;
}
.grid-2 { .grid-2 {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -408,12 +469,23 @@ onMounted(() => {
font-size: 0.8rem; font-size: 0.8rem;
} }
.preview-cell.empty {
background: #fff8e1;
color: #b26a00;
}
.form-success { .form-success {
color: #2e7d32; color: #2e7d32;
font-size: 0.875rem; font-size: 0.875rem;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.form-warn {
color: #b26a00;
font-size: 0.875rem;
margin: 0 0 0.75rem;
}
.actions { .actions {
display: flex; display: flex;
gap: 0.35rem; gap: 0.35rem;