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

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