2026-07-18 11:21:51 +02:00
|
|
|
"""KI query understanding and result aggregation via LiteLLM."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
2026-07-19 02:22:25 +02:00
|
|
|
import uuid
|
2026-07-18 11:21:51 +02:00
|
|
|
from typing import Any
|
|
|
|
|
|
2026-07-19 02:22:25 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-07-18 11:21:51 +02:00
|
|
|
|
2026-08-16 01:17:18 +02:00
|
|
|
from app.ai.llm_client import llm_complete
|
|
|
|
|
|
2026-07-18 11:21:51 +02:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
QUERY_ANALYZE_SYSTEM = (
|
|
|
|
|
"Du bist ein Query-Analyzer fuer ein CRM. "
|
|
|
|
|
"Analysiere die Suchanfrage und gib JSON zurueck: "
|
2026-07-23 17:29:53 +02:00
|
|
|
'{"normalized_query": str, "entities": {"person": str|null, "contact": str|null, "topic": str|null}, '
|
2026-07-18 11:21:51 +02:00
|
|
|
'"intent": str, "semantic_terms": [str], "suggested_filters": {}}'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
RESULT_AGGREGATE_SYSTEM = (
|
|
|
|
|
"Du bist ein Result-Aggregator. Fasse Ergebnisse zusammen und generiere Facetten: "
|
|
|
|
|
'{"summary": str, "facets": {"types": {}, "dates": {}, "people": []}, "suggestions": [str]}'
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-14 01:34:58 +02:00
|
|
|
# Sensible default model used when no provider-specific model is configured.
|
|
|
|
|
# llm_complete() resolves credentials/model prefix from the central config.
|
|
|
|
|
DEFAULT_LLM_MODEL = "ollama/deepseek-v4-flash"
|
2026-07-19 02:22:25 +02:00
|
|
|
|
|
|
|
|
|
2026-07-18 11:21:51 +02:00
|
|
|
def _fallback_query_analysis(query: str) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"normalized_query": query,
|
|
|
|
|
"entities": {},
|
|
|
|
|
"intent": "search",
|
|
|
|
|
"semantic_terms": [],
|
|
|
|
|
"suggested_filters": {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fallback_aggregate(results: list[dict], query: str) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"summary": f"{len(results)} Ergebnisse gefunden",
|
|
|
|
|
"facets": {},
|
|
|
|
|
"suggestions": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 01:34:58 +02:00
|
|
|
def _parse_json_content(content: str) -> dict[str, Any]:
|
|
|
|
|
"""Parse LLM JSON response, stripping markdown code fences if present."""
|
|
|
|
|
content = content.strip()
|
|
|
|
|
if content.startswith("```"):
|
|
|
|
|
content = content.split("\n", 1)[-1] if "\n" in content else content[3:]
|
|
|
|
|
if content.endswith("```"):
|
|
|
|
|
content = content[:-3].strip()
|
|
|
|
|
return json.loads(content)
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 02:22:25 +02:00
|
|
|
async def llm_analyze_query(
|
|
|
|
|
query: str,
|
|
|
|
|
db: AsyncSession | None = None,
|
|
|
|
|
tenant_id: uuid.UUID | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
2026-07-18 11:21:51 +02:00
|
|
|
"""Analyze a search query using LLM for intent, entities, and semantic terms.
|
|
|
|
|
|
|
|
|
|
Falls back to a simple dict if LLM fails.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
2026-08-13 16:22:05 +02:00
|
|
|
result = await llm_complete(
|
2026-08-14 01:34:58 +02:00
|
|
|
model=DEFAULT_LLM_MODEL,
|
2026-07-18 11:21:51 +02:00
|
|
|
messages=[
|
|
|
|
|
{"role": "system", "content": QUERY_ANALYZE_SYSTEM},
|
|
|
|
|
{"role": "user", "content": query},
|
|
|
|
|
],
|
|
|
|
|
temperature=0.1,
|
|
|
|
|
max_tokens=500,
|
|
|
|
|
response_format={"type": "json_object"},
|
2026-08-14 01:34:58 +02:00
|
|
|
db=db,
|
|
|
|
|
tenant_id=tenant_id,
|
2026-07-18 11:21:51 +02:00
|
|
|
)
|
2026-08-14 01:34:58 +02:00
|
|
|
return _parse_json_content(result["content"])
|
2026-07-18 11:21:51 +02:00
|
|
|
except Exception:
|
2026-07-19 02:22:25 +02:00
|
|
|
logger.warning("LLM query analysis failed, using fallback", exc_info=True)
|
2026-07-18 11:21:51 +02:00
|
|
|
return _fallback_query_analysis(query)
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 02:22:25 +02:00
|
|
|
async def llm_aggregate_results(
|
|
|
|
|
results: list[dict],
|
|
|
|
|
query: str,
|
|
|
|
|
db: AsyncSession | None = None,
|
|
|
|
|
tenant_id: uuid.UUID | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
2026-07-18 11:21:51 +02:00
|
|
|
"""Aggregate search results using LLM for summary, facets, and suggestions.
|
|
|
|
|
|
|
|
|
|
Falls back to a simple dict if LLM fails.
|
|
|
|
|
"""
|
|
|
|
|
if not results:
|
|
|
|
|
return _fallback_aggregate(results, query)
|
|
|
|
|
try:
|
|
|
|
|
# Truncate results to avoid token overflow
|
|
|
|
|
compact = [
|
|
|
|
|
{"entity_type": r.get("entity_type"), "title": r.get("title", "")[:100]}
|
|
|
|
|
for r in results[:50]
|
|
|
|
|
]
|
|
|
|
|
user_msg = json.dumps({"query": query, "results": compact})
|
2026-07-19 02:22:25 +02:00
|
|
|
|
2026-08-13 16:22:05 +02:00
|
|
|
result = await llm_complete(
|
2026-08-14 01:34:58 +02:00
|
|
|
model=DEFAULT_LLM_MODEL,
|
2026-07-18 11:21:51 +02:00
|
|
|
messages=[
|
|
|
|
|
{"role": "system", "content": RESULT_AGGREGATE_SYSTEM},
|
|
|
|
|
{"role": "user", "content": user_msg},
|
|
|
|
|
],
|
|
|
|
|
temperature=0.1,
|
|
|
|
|
max_tokens=1000,
|
|
|
|
|
response_format={"type": "json_object"},
|
2026-08-14 01:34:58 +02:00
|
|
|
db=db,
|
|
|
|
|
tenant_id=tenant_id,
|
2026-07-18 11:21:51 +02:00
|
|
|
)
|
2026-08-14 01:34:58 +02:00
|
|
|
return _parse_json_content(result["content"])
|
2026-07-18 11:21:51 +02:00
|
|
|
except Exception:
|
2026-07-19 02:22:25 +02:00
|
|
|
logger.warning("LLM result aggregation failed, using fallback", exc_info=True)
|
2026-07-18 11:21:51 +02:00
|
|
|
return _fallback_aggregate(results, query)
|