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):
sample_post: str = Field(min_length=1)
hint: str | None = None
current_profile: dict[str, Any] | None = None
class GenerateResponse(BaseModel):
profile: dict[str, Any]
preview: dict[str, str]
empty_fields: list[str] = Field(default_factory=list)
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.",
)
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:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
@@ -73,7 +81,13 @@ async def generate_profile(payload: GenerateRequest):
status_code=502,
detail=f"DeepSeek generate failed: {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)
@@ -22,7 +22,8 @@ logger = logging.getLogger("ca.parser_builder")
GENERATE_SYSTEM = (
"You design a static heuristic parser profile for structured Telegram posts. "
"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)
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]:
content = (content or "").strip()
if content.startswith("```"):
@@ -63,21 +68,11 @@ def _extract_json_object(content: str) -> dict[str, Any]:
return parsed
async def generate_profile(sample_post: str) -> 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")
def _base_user_prompt(sample: str) -> str:
fields_help = "\n".join(
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"
"Schema:\n"
'{"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"
f"- Allowed field names: {', '.join(TARGET_FIELDS)}.\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"
"- event_date should capture DD.MM.YYYY or similar.\n"
"- Do not invent Python code; only declarative rules.\n\n"
"- event_date should capture DD.MM.YYYY / DD.MM.YY or similar.\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"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 = {
"model": settings["model"],
"messages": [
@@ -121,10 +190,4 @@ async def generate_profile(sample_post: str) -> HeuristicProfile:
data = response.json()
content = data["choices"][0]["message"]["content"]
raw = _extract_json_object(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)
return _parse_profile_response(content)