You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
212 lines
7.5 KiB
Python
212 lines
7.5 KiB
Python
"""LLM parser using Ollama: cache lookup → prompt → validate → store."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from libs.common.logging import get_logger
|
|
from libs.llm.cache import LLMCacheStore, build_cache_key
|
|
from libs.llm.client import OllamaClient
|
|
from libs.llm.exceptions import LLMError, LLMSchemaError
|
|
from libs.llm.prompts import PROMPT_VERSION, render_prompt
|
|
from libs.parser.schema_validator import validate_parser_output
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
_SCHEMA_VERSION = "1.0.0"
|
|
_PROMPT_NAME = "event_classifier_v1"
|
|
|
|
# Fields returned by LLM that map to ParserEventOutput fields
|
|
_REQUIRED_FIELDS = {
|
|
"event_type",
|
|
"event_direction",
|
|
"headline",
|
|
"summary",
|
|
"guidance_status",
|
|
"confidence_overall",
|
|
}
|
|
|
|
|
|
def _llm_response_to_parser_output(
|
|
document_id: str,
|
|
doc_meta: dict[str, Any],
|
|
raw: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Convert flat LLM response dict → ParserEventOutput-compatible dict."""
|
|
return {
|
|
"schema_version": _SCHEMA_VERSION,
|
|
"document_id": document_id,
|
|
"parser_kind": "llm",
|
|
"event_type": raw.get("event_type", "unknown"),
|
|
"event_direction": raw.get("event_direction", "unknown"),
|
|
"event_date": doc_meta.get("filing_date", "1900-01-01"),
|
|
"filing_time_bucket": doc_meta.get("filing_time_bucket", "unknown"),
|
|
"headline": raw.get("headline", ""),
|
|
"summary": raw.get("summary", ""),
|
|
"guidance": {
|
|
"status": raw.get("guidance_status", "unclear"),
|
|
"scope": raw.get("guidance_scope", "unknown"),
|
|
"notes": "",
|
|
},
|
|
"signals": {
|
|
"demand_strength": raw.get("demand_strength", "unknown"),
|
|
"pricing_power": raw.get("pricing_power", "unknown"),
|
|
"backlog_or_bookings": raw.get("backlog_or_bookings", "unknown"),
|
|
"customer_expansion": raw.get("customer_expansion", "unknown"),
|
|
"margin_quality": raw.get("margin_quality", "unknown"),
|
|
},
|
|
"risk_flags": {
|
|
"oneoff_item": bool(raw.get("oneoff_item", False)),
|
|
"tax_benefit": bool(raw.get("tax_benefit", False)),
|
|
"valuation_gain": bool(raw.get("valuation_gain", False)),
|
|
"non_gaap_heavy": bool(raw.get("non_gaap_heavy", False)),
|
|
"financing_related": bool(raw.get("financing_related", False)),
|
|
"legal_or_regulatory_overhang": bool(
|
|
raw.get("legal_or_regulatory_overhang", False)
|
|
),
|
|
},
|
|
"evidence": [],
|
|
"confidence": {
|
|
"overall": float(raw.get("confidence_overall", 0.5)),
|
|
"event_type": float(raw.get("confidence_event_type", 0.5)),
|
|
"event_direction": float(raw.get("confidence_event_direction", 0.5)),
|
|
"guidance": 0.5,
|
|
"risk_flags": 0.5,
|
|
},
|
|
"warnings": [],
|
|
}
|
|
|
|
|
|
def _repair_prompt(
|
|
original_messages: list[dict[str, str]],
|
|
errors: list[str],
|
|
raw_response: str,
|
|
) -> list[dict[str, str]]:
|
|
"""Append a repair instruction to messages asking LLM to fix schema errors."""
|
|
repair_msg = (
|
|
f"Your previous response had schema errors: {errors[:3]}. "
|
|
f"Previous response was: {raw_response[:300]}. "
|
|
"Please fix these issues and return only valid JSON matching the schema."
|
|
)
|
|
return [*original_messages, {"role": "assistant", "content": raw_response}, {"role": "user", "content": repair_msg}]
|
|
|
|
|
|
class LLMParser:
|
|
"""Parse a document with Ollama, using DB cache to avoid redundant calls."""
|
|
|
|
def __init__(
|
|
self,
|
|
client: OllamaClient,
|
|
cache_store: LLMCacheStore | None = None,
|
|
) -> None:
|
|
self._client = client
|
|
self._cache = cache_store or LLMCacheStore()
|
|
|
|
async def parse(
|
|
self,
|
|
document_id: str,
|
|
doc_text: str,
|
|
doc_meta: dict[str, Any],
|
|
rule_hints: dict[str, Any],
|
|
session: AsyncSession,
|
|
) -> dict[str, Any] | None:
|
|
"""Parse a document with LLM.
|
|
|
|
Returns a ParserEventOutput-compatible dict or None on failure.
|
|
"""
|
|
cache_key = build_cache_key(
|
|
text=doc_text,
|
|
prompt_name=_PROMPT_NAME,
|
|
prompt_version=PROMPT_VERSION,
|
|
model_name=self._client.model,
|
|
schema_version=_SCHEMA_VERSION,
|
|
)
|
|
|
|
# 1. Cache lookup
|
|
cached = await self._cache.get(session, cache_key)
|
|
if cached is not None:
|
|
logger.info("llm_parse_cache_hit", document_id=document_id)
|
|
return cached
|
|
|
|
# 2. Render prompt
|
|
messages = render_prompt(
|
|
prompt_name=_PROMPT_NAME,
|
|
doc_meta=doc_meta,
|
|
text=doc_text,
|
|
rule_hints=rule_hints,
|
|
)
|
|
raw_prompt_str = json.dumps(messages)
|
|
|
|
# 3. Call Ollama
|
|
try:
|
|
raw_dict, token_usage, elapsed_ms = await self._client.chat(messages)
|
|
except LLMError as exc:
|
|
logger.error("llm_parse_failed", document_id=document_id, error=str(exc))
|
|
return None
|
|
|
|
raw_response_str = json.dumps(raw_dict)
|
|
|
|
# 4. Map to parser output format
|
|
output = _llm_response_to_parser_output(document_id, doc_meta, raw_dict)
|
|
|
|
# 5. Validate schema
|
|
errors = validate_parser_output(output)
|
|
if errors:
|
|
logger.warning("llm_schema_errors_attempt_repair", errors=errors[:3])
|
|
repair_messages = _repair_prompt(messages, errors, raw_response_str)
|
|
try:
|
|
raw_dict2, token_usage2, elapsed_ms2 = await self._client.chat(repair_messages)
|
|
token_usage = {
|
|
"prompt_tokens": token_usage.get("prompt_tokens", 0)
|
|
+ token_usage2.get("prompt_tokens", 0),
|
|
"completion_tokens": token_usage.get("completion_tokens", 0)
|
|
+ token_usage2.get("completion_tokens", 0),
|
|
}
|
|
elapsed_ms += elapsed_ms2
|
|
raw_response_str = json.dumps(raw_dict2)
|
|
output = _llm_response_to_parser_output(document_id, doc_meta, raw_dict2)
|
|
errors = validate_parser_output(output)
|
|
except LLMError as exc:
|
|
logger.error("llm_repair_failed", document_id=document_id, error=str(exc))
|
|
return None
|
|
|
|
if errors:
|
|
logger.error(
|
|
"llm_schema_invalid_after_repair",
|
|
document_id=document_id,
|
|
errors=errors[:3],
|
|
)
|
|
raise LLMSchemaError(
|
|
f"LLM output invalid after repair: {errors[:2]}",
|
|
source="llm_parser",
|
|
context={"document_id": document_id},
|
|
)
|
|
|
|
# 6. Store in cache
|
|
try:
|
|
await self._cache.put(
|
|
session=session,
|
|
cache_key=cache_key,
|
|
document_id=document_id,
|
|
model_name=self._client.model,
|
|
prompt_version=PROMPT_VERSION,
|
|
schema_version=_SCHEMA_VERSION,
|
|
raw_prompt=raw_prompt_str,
|
|
raw_response=raw_response_str,
|
|
normalized=output,
|
|
token_usage=token_usage,
|
|
elapsed_ms=elapsed_ms,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("llm_cache_write_failed", error=str(exc))
|
|
|
|
logger.info(
|
|
"llm_parse_ok",
|
|
document_id=document_id,
|
|
elapsed_ms=elapsed_ms,
|
|
event_type=output.get("event_type"),
|
|
)
|
|
return output
|