chore(agent): uncommitted changes from task
This commit is contained in:
1 parent
efad39ea23
commit
be384224cd
2 files changed
+253
-4
No files matched your search
@@ -104,6 +104,11 @@ ATURAN ANGKA & ARAH (WAJIB DIPATUHI):
|
||||
|
||||
FORMAT OUTPUT (JSON SAJA):
|
||||
{"kesimpulan":"...","insight":"..."}
|
||||
ATURAN FORMAT NARASI (WAJIB — BERLAKU UNTUK SEMUA TOPIK):
|
||||
- "kesimpulan" dan "insight" adalah paragraf naratif utuh yang mengalir,
|
||||
sama seperti insight halaman lain — bukan daftar, bukan poin-poin.
|
||||
- DILARANG menulis baris per-metrik seperti "fcr: ..." / "bw: ..." dan
|
||||
DILARANG menyalin pesan [GRADED FACTS] mentah — rangkai menjadi analisis utuh.
|
||||
"""
|
||||
|
||||
END_CYCLE_OUTPUT_RULES = """
|
||||
@@ -462,18 +467,69 @@ def call_ollama(system_prompt: str, user_prompt: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_string_newlines(fragment: str) -> str:
|
||||
"""Replace raw newlines/tabs inside JSON string literals with spaces.
|
||||
|
||||
qwen2.5:3b wraps long string values across real line breaks, which is
|
||||
invalid JSON; without this every long narration fell back to one-liners.
|
||||
"""
|
||||
out: list[str] = []
|
||||
in_str = False
|
||||
i = 0
|
||||
while i < len(fragment):
|
||||
ch = fragment[i]
|
||||
if in_str:
|
||||
if ch == "\\" and i + 1 < len(fragment):
|
||||
out.append(ch)
|
||||
out.append(fragment[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = False
|
||||
out.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch in "\n\r\t":
|
||||
out.append(" ")
|
||||
i += 1
|
||||
continue
|
||||
elif ch == '"':
|
||||
in_str = True
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def parse_llm_json(text: str) -> dict[str, str] | None:
|
||||
if not text:
|
||||
return None
|
||||
cleaned = re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.I)
|
||||
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned.strip(), flags=re.I)
|
||||
# Strip fence MARKERS only (FE insightParse parity) — removing the whole
|
||||
# ```…``` block deleted the JSON itself and forced the local fallback.
|
||||
cleaned = re.sub(r"^```(?:json)?\s*", "", text.strip(), flags=re.I)
|
||||
cleaned = re.sub(r"\s*```\s*$", "", cleaned)
|
||||
# Tolerate small-model JSON noise (FE parser parity): // and /* */ comments,
|
||||
# trailing commas. Lookbehind keeps "http://" URLs intact.
|
||||
cleaned = re.sub(r"(?<!:)//[^\n]*", "", cleaned)
|
||||
cleaned = re.sub(r"/\*.*?\*/", "", cleaned, flags=re.S)
|
||||
cleaned = re.sub(r",(\s*[}\]])", r"\1", cleaned)
|
||||
first = cleaned.find("{")
|
||||
last = cleaned.rfind("}")
|
||||
if first < 0 or last <= first:
|
||||
# No JSON object — the model wrote free prose; keep it as the narrative
|
||||
# instead of dropping it to the per-metric local fallback.
|
||||
prose = cleaned.strip()
|
||||
if len(prose) < 100:
|
||||
return None
|
||||
parts = re.split(r"(?<=[.!?])\s+", prose, maxsplit=1)
|
||||
kesimpulan = parts[0].strip()
|
||||
insight = parts[1].strip() if len(parts) > 1 else kesimpulan
|
||||
return {"kesimpulan": kesimpulan, "insight": insight}
|
||||
fragment = cleaned[first : last + 1]
|
||||
try:
|
||||
parsed = json.loads(cleaned[first : last + 1])
|
||||
parsed = json.loads(fragment)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
parsed = json.loads(_normalize_string_newlines(fragment))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
@@ -491,7 +547,15 @@ def parse_llm_json(text: str) -> dict[str, str] | None:
|
||||
or ""
|
||||
)
|
||||
if not kesimpulan and not insight:
|
||||
# Model invented its own keys (e.g. per-metric fcr/bw/mortality) — fold
|
||||
# the values into narrative paragraphs rather than failing to fallback.
|
||||
values = [str(v).strip() for v in parsed.values() if isinstance(v, str) and v.strip()]
|
||||
if not values:
|
||||
return None
|
||||
return {
|
||||
"kesimpulan": values[0],
|
||||
"insight": "\n\n".join(values[1:]) or values[0],
|
||||
}
|
||||
return {
|
||||
"kesimpulan": str(kesimpulan) or "Model tidak mengembalikan kesimpulan eksplisit.",
|
||||
"insight": str(insight) or "Model tidak mengembalikan rekomendasi eksplisit.",
|
||||
@@ -608,6 +672,12 @@ def enforce_mortality_wording(
|
||||
|
||||
_DUP_KANDANG = re.compile(r"\b[Kk]andang(?:\s+[Kk]andang)+\b")
|
||||
|
||||
# Line-start metric labels ("fcr: ...", "bw: ...") — per-metric one-liners the
|
||||
# model (or local fallback) may emit instead of narrative paragraphs.
|
||||
_METRIC_LABEL_LINE = re.compile(
|
||||
r"(?mi)^\s*(?:fcr|eef|bw|bobot|mortality|mortalitas|environment|ip)\s*:\s*(?=\S)"
|
||||
)
|
||||
|
||||
|
||||
def collapse_duplicate_kandang(text: str) -> str:
|
||||
"""Fix LLM slip 'Kandang Kandang 2' → 'Kandang 2' (name already includes Kandang)."""
|
||||
@@ -616,12 +686,23 @@ def collapse_duplicate_kandang(text: str) -> str:
|
||||
return _DUP_KANDANG.sub(lambda m: "Kandang" if m.group(0)[0].isupper() else "kandang", text)
|
||||
|
||||
|
||||
def strip_metric_label_lines(text: str) -> str:
|
||||
"""Drop `metric: ` line labels and flow the lines into one paragraph."""
|
||||
if not text:
|
||||
return text
|
||||
stripped = _METRIC_LABEL_LINE.sub("", text)
|
||||
if stripped == text:
|
||||
return text
|
||||
return re.sub(r"\s*\n+\s*", " ", stripped).strip()
|
||||
|
||||
|
||||
def sanitize_insight_narrative(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Deterministic cleanup of narrative fields after the LLM."""
|
||||
out = dict(parsed)
|
||||
for key in ("kesimpulan", "insight", "summary", "insight_text"):
|
||||
if key in out and out[key] is not None:
|
||||
out[key] = collapse_duplicate_kandang(str(out[key]))
|
||||
text = collapse_duplicate_kandang(str(out[key]))
|
||||
out[key] = strip_metric_label_lines(text)
|
||||
return out
|
||||
|
||||
|
||||
@@ -869,6 +950,11 @@ def generate_insight(
|
||||
else:
|
||||
parsed = parse_llm_json(raw or "")
|
||||
if not parsed:
|
||||
logger.warning(
|
||||
"Daily LLM output unparseable (topic=%s); local fallback. raw[:400]=%r",
|
||||
topic,
|
||||
(raw or "")[:400],
|
||||
)
|
||||
parsed = local_fallback_insight(graded, topic)
|
||||
else:
|
||||
parsed = enforce_mortality_wording(parsed, graded)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Narrate-stage robustness: FCR/EEF daily insights must come out as narrative
|
||||
paragraphs, not per-metric one-liners (`fcr: ...`, `bw: ...`)."""
|
||||
|
||||
import re
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.operations.services.insight_service import (
|
||||
ANTI_HALLUCINATION_RULES,
|
||||
local_fallback_insight,
|
||||
parse_llm_json,
|
||||
sanitize_insight_narrative,
|
||||
)
|
||||
|
||||
_METRIC_ONE_LINER = re.compile(
|
||||
r"(?mi)^\s*(?:fcr|eef|bw|bobot|mortality|mortalitas|environment)\s*:\s*\S"
|
||||
)
|
||||
|
||||
|
||||
class ParseLlmJsonTests(SimpleTestCase):
|
||||
def test_parses_standard_two_field_json(self):
|
||||
raw = '{"kesimpulan":"Kesimpulan utuh.","insight":"Paragraf insight utuh."}'
|
||||
out = parse_llm_json(raw)
|
||||
self.assertIsNotNone(out)
|
||||
self.assertEqual(out["kesimpulan"], "Kesimpulan utuh.")
|
||||
self.assertEqual(out["insight"], "Paragraf insight utuh.")
|
||||
|
||||
def test_tolerates_trailing_comma_and_line_comment(self):
|
||||
raw = (
|
||||
"{\n"
|
||||
' "kesimpulan": "Kesimpulan utuh.",\n'
|
||||
' "insight": "Paragraf insight utuh.",\n'
|
||||
" // model menulis komentar\n"
|
||||
"}"
|
||||
)
|
||||
out = parse_llm_json(raw)
|
||||
self.assertIsNotNone(out)
|
||||
self.assertEqual(out["kesimpulan"], "Kesimpulan utuh.")
|
||||
|
||||
def test_salvages_metric_keyed_json_into_narrative_without_label_lines(self):
|
||||
"""Model echoing graded metrics as keys must not yield one-liners."""
|
||||
raw = (
|
||||
'{"fcr": "FCR 1.70 melebihi standar CP 707 (1.615) pada hari ke-48.",'
|
||||
' "bw": "Bobot badan aktual 142.1g jauh di bawah standar CP 707 (231g).",'
|
||||
' "mortality": "Data mortalitas tidak tersedia."}'
|
||||
)
|
||||
out = parse_llm_json(raw)
|
||||
self.assertIsNotNone(out)
|
||||
self.assertTrue(out["kesimpulan"].startswith("FCR 1.70"))
|
||||
self.assertIn("Bobot badan aktual", out["insight"])
|
||||
self.assertIn("Data mortalitas tidak tersedia", out["insight"])
|
||||
self.assertFalse(_METRIC_ONE_LINER.search(out["kesimpulan"]))
|
||||
self.assertFalse(_METRIC_ONE_LINER.search(out["insight"]))
|
||||
|
||||
def test_salvages_prose_response_without_json(self):
|
||||
raw = (
|
||||
"Kandang 1 pada hari ke-8 menunjukkan FCR 0.18 yang lebih baik dari "
|
||||
"standar CP 707 (0.864), sehingga manajemen pakan tampak efisien. "
|
||||
"Namun bobot badan 142.1 gram masih jauh di bawah standar 231 gram "
|
||||
"dan perlu ditindaklanjuti dengan grading segera agar pertumbuhan "
|
||||
"tidak tertinggal dari target siklus."
|
||||
)
|
||||
out = parse_llm_json(raw)
|
||||
self.assertIsNotNone(out)
|
||||
self.assertIn("FCR 0.18", out["kesimpulan"])
|
||||
self.assertIn("grading segera", out["insight"])
|
||||
self.assertFalse(_METRIC_ONE_LINER.search(out["kesimpulan"]))
|
||||
|
||||
def test_tolerates_literal_newlines_inside_json_strings(self):
|
||||
"""qwen2.5:3b wraps JSON string values across raw lines — seen live."""
|
||||
raw = (
|
||||
"```json\n"
|
||||
"{\n"
|
||||
' "kesimpulan": "Pada hari ke-8 di\n'
|
||||
'Kandang 1, FCR 0.18 lebih rendah dari standar CP 707 (0.864).",\n'
|
||||
' "insight": "Manajemen pakan perlu dijaga\n'
|
||||
'agar FCR tetap ideal hingga panen."\n'
|
||||
"}\n"
|
||||
"```"
|
||||
)
|
||||
out = parse_llm_json(raw)
|
||||
self.assertIsNotNone(out)
|
||||
self.assertIn("Kandang 1", out["kesimpulan"])
|
||||
self.assertIn("FCR 0.18", out["kesimpulan"])
|
||||
self.assertIn("hingga panen", out["insight"])
|
||||
|
||||
def test_short_junk_without_json_returns_none(self):
|
||||
self.assertIsNone(parse_llm_json("Maaf, saya tidak bisa."))
|
||||
|
||||
|
||||
class SanitizeNarrativeTests(SimpleTestCase):
|
||||
def test_strips_metric_label_lines_into_paragraph(self):
|
||||
out = sanitize_insight_narrative(
|
||||
{
|
||||
"kesimpulan": (
|
||||
"bw: Bobot badan aktual 142.1g jauh di bawah standar "
|
||||
"CP 707 (231g) pada hari ke-8."
|
||||
),
|
||||
"insight": (
|
||||
"fcr: FCR 0.18 lebih baik dari standar CP 707 (0.864).\n"
|
||||
"mortality: Data mortalitas tidak tersedia."
|
||||
),
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
out["kesimpulan"],
|
||||
"Bobot badan aktual 142.1g jauh di bawah standar CP 707 (231g) pada hari ke-8.",
|
||||
)
|
||||
self.assertFalse(_METRIC_ONE_LINER.search(out["insight"]))
|
||||
self.assertIn("FCR 0.18", out["insight"])
|
||||
self.assertIn("Data mortalitas tidak tersedia", out["insight"])
|
||||
self.assertNotIn("\n", out["insight"])
|
||||
|
||||
def test_leaves_normal_paragraphs_and_colon_words_alone(self):
|
||||
parsed = {
|
||||
"kesimpulan": "Catatan lapangan: data lengkap.",
|
||||
"insight": "Paragraf pertama.\n\nParagraf kedua.",
|
||||
}
|
||||
out = sanitize_insight_narrative(parsed)
|
||||
self.assertEqual(out["kesimpulan"], parsed["kesimpulan"])
|
||||
self.assertEqual(out["insight"], parsed["insight"])
|
||||
|
||||
def test_still_collapses_duplicate_kandang(self):
|
||||
out = sanitize_insight_narrative(
|
||||
{"kesimpulan": "Kandang Kandang 2 mortalitas tinggi.", "insight": "Pantau."}
|
||||
)
|
||||
self.assertEqual(out["kesimpulan"], "Kandang 2 mortalitas tinggi.")
|
||||
|
||||
|
||||
class LocalFallbackNarrativeTests(SimpleTestCase):
|
||||
GRADED = {
|
||||
"analyses": {
|
||||
"bw": {
|
||||
"status": "critical",
|
||||
"message": "Bobot badan aktual 142.1g jauh di bawah standar CP 707 (231g).",
|
||||
},
|
||||
"fcr": {"status": "ok", "message": "FCR 0.18 sesuai standar CP 707 (0.864)."},
|
||||
"mortality": {"status": "unknown", "message": "Data mortalitas tidak tersedia."},
|
||||
}
|
||||
}
|
||||
|
||||
def test_fallback_through_sanitize_has_no_metric_one_liners(self):
|
||||
out = sanitize_insight_narrative(local_fallback_insight(dict(self.GRADED), "fcr"))
|
||||
self.assertFalse(_METRIC_ONE_LINER.search(out["kesimpulan"]))
|
||||
self.assertFalse(_METRIC_ONE_LINER.search(out["insight"]))
|
||||
self.assertIn("Bobot badan aktual", out["kesimpulan"])
|
||||
self.assertIn("FCR 0.18", out["insight"])
|
||||
|
||||
def test_fallback_without_messages_still_reports_unknown(self):
|
||||
out = local_fallback_insight({"analyses": {}}, "eef")
|
||||
self.assertIn("tidak cukup", out["kesimpulan"])
|
||||
|
||||
|
||||
class NarrativePromptRuleTests(SimpleTestCase):
|
||||
def test_rules_require_paragraph_output_and_ban_metric_one_liners(self):
|
||||
rules = ANTI_HALLUCINATION_RULES.lower()
|
||||
self.assertIn("paragraf", rules)
|
||||
self.assertIn("fcr: ...", rules)
|
||||
|
||||
def test_end_cycle_format_replace_target_still_present(self):
|
||||
"""generate_insight swaps this exact block for end-cycle rules."""
|
||||
target = 'FORMAT OUTPUT (JSON SAJA):\n{"kesimpulan":"...","insight":"..."}'
|
||||
self.assertIn(target, ANTI_HALLUCINATION_RULES)
|
||||
Reference in new issue
Block a user