- Backend: FastAPI + SQLAlchemy + Celery (Python 3.11+) - Frontend: Vue 3 + TypeScript + Pinia + Tailwind - Admin Frontend: separate Vue 3 app for management - Docker Compose: 9 services orchestration - Specs: design prototypes, memory system PRD, product roadmap Cleanup performed: - Removed temporary debug scripts from backend root - Removed deprecated admin_app.py (embedded UI) - Removed duplicate docs from admin-frontend - Updated .gitignore for Vite cache and egg-info
86 lines
2.5 KiB
Python
86 lines
2.5 KiB
Python
"""Achievement extraction service."""
|
|
|
|
import json
|
|
import re
|
|
|
|
import httpx
|
|
|
|
from app.core.config import settings
|
|
from app.core.logging import get_logger
|
|
from app.core.prompts import ACHIEVEMENT_EXTRACTION_PROMPT
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
TEXT_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
|
|
|
|
|
async def extract_achievements(story_text: str) -> list[dict]:
|
|
"""Extract achievements from story text using LLM."""
|
|
if not settings.text_api_key:
|
|
logger.warning("achievement_extraction_skipped", reason="missing_text_api_key")
|
|
return []
|
|
|
|
model = settings.text_model or "gemini-2.0-flash"
|
|
url = f"{TEXT_API_BASE}/{model}:generateContent"
|
|
|
|
prompt = ACHIEVEMENT_EXTRACTION_PROMPT.format(story_text=story_text)
|
|
payload = {
|
|
"contents": [{"parts": [{"text": prompt}]}],
|
|
"generationConfig": {
|
|
"responseMimeType": "application/json",
|
|
"temperature": 0.2,
|
|
"topP": 0.9,
|
|
},
|
|
}
|
|
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
response = await client.post(
|
|
url,
|
|
json=payload,
|
|
headers={"x-goog-api-key": settings.text_api_key},
|
|
)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
|
|
candidates = result.get("candidates") or []
|
|
if not candidates:
|
|
logger.warning("achievement_extraction_empty")
|
|
return []
|
|
|
|
parts = candidates[0].get("content", {}).get("parts") or []
|
|
if not parts or "text" not in parts[0]:
|
|
logger.warning("achievement_extraction_missing_text")
|
|
return []
|
|
|
|
response_text = parts[0]["text"]
|
|
clean_json = response_text
|
|
if response_text.startswith("```json"):
|
|
clean_json = re.sub(r"^```json\n|```$", "", response_text)
|
|
|
|
try:
|
|
parsed = json.loads(clean_json)
|
|
except json.JSONDecodeError:
|
|
logger.warning("achievement_extraction_parse_failed")
|
|
return []
|
|
|
|
achievements = parsed.get("achievements")
|
|
if not isinstance(achievements, list):
|
|
return []
|
|
|
|
normalized: list[dict] = []
|
|
for item in achievements:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
a_type = str(item.get("type", "")).strip()
|
|
description = str(item.get("description", "")).strip()
|
|
score = item.get("score", 0)
|
|
if not a_type or not description:
|
|
continue
|
|
normalized.append({
|
|
"type": a_type,
|
|
"description": description,
|
|
"score": score
|
|
})
|
|
|
|
return normalized
|