"""Ekstraksi entitas mimpi ke JSON via Qwen GGUF.""" from __future__ import annotations import os import time from typing import Any from model.loader import get_model, kunci_inferensi from model.normalisasi import ( entitas_memadai, parse_json_dari_teks, validasi_dan_perbaiki, validasi_skema_json, ) VERSI_PROMPT = "oneiros_v1" CONTOH_JSON_FEW_SHOT = """{ "title": "The White Deer Forest", "mood": "mysterious", "characters": [ {"name": "Dreamer", "role": "self", "emotion": "afraid"}, {"name": "White Deer", "role": "creature", "emotion": "elusive"} ], "places": [ {"name": "Night Forest", "type": "fantastical", "feeling": "chasing"} ], "symbols": [ {"name": "Changing Face", "appearance": "friend face shifts", "significance": "identity"} ], "connections": [ {"from": "Dreamer", "to": "White Deer", "type": "pursuit"}, {"from": "Dreamer", "to": "Night Forest", "type": "tension"} ], "core_theme": "Chasing something familiar that stays out of reach" }""" SYSTEM_PROMPT = f"""You are Oneiros, a dream cartographer. Your only job is to extract structured entities from dream narratives and return valid JSON. RULES: - Return ONLY valid JSON, no markdown, no explanation, no preamble - If a field is uncertain, use your best interpretation - "connections" should capture the most emotionally significant relationships - Keep all text short: max 5 words per field value - Mood must be exactly one of: mysterious, joyful, anxious, surreal, peaceful EXAMPLE OUTPUT (follow this structure exactly): {CONTOH_JSON_FEW_SHOT}""" EXTRACTION_PROMPT = """Extract entities from this dream and return JSON matching exactly this schema: {{ "title": string (3-5 word dream title), "mood": "mysterious|joyful|anxious|surreal|peaceful", "characters": [{{"name": string, "role": "self|known|stranger|creature|archetype", "emotion": string}}], "places": [{{"name": string, "type": "real|distorted|fantastical|transitional", "feeling": string}}], "symbols": [{{"name": string, "appearance": string, "significance": string}}], "connections": [{{"from": string, "to": string, "type": "tension|harmony|pursuit|transformation"}}], "core_theme": string (one sentence) }} DREAM: {dream_text} JSON:""" PROMPT_PERBAIKAN = ( "Your previous response was not valid JSON. " "Return ONLY a single valid JSON object matching the schema. No markdown." ) def _panggil_model(pesan_sistem: str, pesan_user: str) -> str: model = get_model() with kunci_inferensi(): respons = model.create_chat_completion( messages=[ {"role": "system", "content": pesan_sistem}, {"role": "user", "content": pesan_user}, ], max_tokens=800, temperature=0.2, top_p=0.9, ) isi = respons["choices"][0]["message"]["content"] return (isi or "").strip() def _parse_dengan_retry(prompt: str, raw_awal: str) -> tuple[dict | None, str, str | None]: """Parse output; satu retry jika gagal. Kembalikan (data, raw_final, raw_pertama).""" raw_pertama: str | None = None raw = raw_awal data, error = parse_json_dari_teks(raw) if data is None: raw_pertama = raw raw = _panggil_model( SYSTEM_PROMPT, prompt + "\n\n" + PROMPT_PERBAIKAN + f"\n\nBroken output:\n{raw[:500]}", ) data, error = parse_json_dari_teks(raw) if data is None: return None, raw, raw_pertama error_skema = validasi_skema_json(data) # Space CPU: default tanpa retry schema (hemat ~10–30s). Lokal: retry aktif. boleh_retry_skema = not os.getenv("SPACE_ID") or os.getenv("ONEIROS_ALLOW_SCHEMA_RETRY") == "1" if os.getenv("ONEIROS_SKIP_SCHEMA_RETRY") == "1": boleh_retry_skema = False if error_skema and boleh_retry_skema: if raw_pertama is None: raw_pertama = raw raw = _panggil_model( SYSTEM_PROMPT, prompt + "\n\n" + PROMPT_PERBAIKAN + f"\n\nSchema error: {error_skema}", ) data2, error2 = parse_json_dari_teks(raw) if data2 is not None: data = data2 error = error2 error_skema = validasi_skema_json(data) else: error = error2 or error_skema if data is not None and error_skema: # Tetap normalisasi; parse_ok ditentukan nanti via entitas_memadai return data, raw, raw_pertama return data, raw, raw_pertama def extract_entities( teks_mimpi: str, ) -> tuple[dict[str, Any], str, bool, str | None, str | None]: """ Ekstrak entitas dari narasi mimpi. Returns: entities, raw_output, parse_ok, parse_error, raw_percobaan_pertama """ teks = teks_mimpi.strip() # Hindari str.format pada teks mimpi (kurung kurawal dari user bisa merusak template) prompt = EXTRACTION_PROMPT.replace("{dream_text}", teks) raw_awal = _panggil_model(SYSTEM_PROMPT, prompt) data, raw, raw_pertama = _parse_dengan_retry(prompt, raw_awal) if data is None: kosong = validasi_dan_perbaiki( { "title": "Parse Failed", "mood": "mysterious", "characters": [], "places": [], "symbols": [], "connections": [], "core_theme": "", } ) return kosong, raw, False, "JSON parse gagal", raw_pertama entities = validasi_dan_perbaiki(data) error_skema = validasi_skema_json(data) parse_ok = error_skema is None and entitas_memadai(entities) parse_error = None if error_skema: parse_error = error_skema elif not entitas_memadai(entities): parse_error = "Entitas kosong setelah normalisasi" return entities, raw, parse_ok, parse_error, raw_pertama def extract_entities_dengan_waktu( teks_mimpi: str, ) -> tuple[dict[str, Any], str, bool, str | None, float, str | None]: """extract_entities + durasi detik + raw_percobaan_pertama.""" mulai = time.perf_counter() entities, raw, ok, err, raw_pertama = extract_entities(teks_mimpi) return entities, raw, ok, err, time.perf_counter() - mulai, raw_pertama