Handle repetitive Semantic Consolidator output loops
This commit is contained in:
@@ -375,6 +375,22 @@ extraction results into `meeting_protocol.md` for technical validation. The
|
|||||||
planned architecture separates Canonical Meeting Knowledge from the final Output
|
planned architecture separates Canonical Meeting Knowledge from the final Output
|
||||||
Views documented in `output-views.md`.
|
Views documented in `output-views.md`.
|
||||||
|
|
||||||
|
## Semantic Consolidator failure handling
|
||||||
|
|
||||||
|
Semantic Consolidator V0 preserves every raw model response before parsing.
|
||||||
|
Its normal path accepts parseable grouping JSON and leaves duplicate source-ID
|
||||||
|
and missing-source-ID correction to the existing deterministic coverage
|
||||||
|
repair.
|
||||||
|
|
||||||
|
An invalid or truncated response is not retried by default. One controlled
|
||||||
|
retry is allowed only when deterministic inspection finds at least three
|
||||||
|
consecutive complete groups with an identical structural signature:
|
||||||
|
`canonical_text`, ordered `source_item_ids` and `merge_reason`. The retry keeps
|
||||||
|
the same model, temperature, context window and generation limit and adds only
|
||||||
|
an instruction not to emit an identical group more than once. Both attempts
|
||||||
|
and the detected repetition metadata are preserved. If the retry also fails,
|
||||||
|
the stage fails normally; it does not make another LLM call.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Next Milestone
|
# Next Milestone
|
||||||
|
|||||||
@@ -931,3 +931,113 @@ BUG-011 and does not invalidate this chunking hypothesis.
|
|||||||
|
|
||||||
This verifies the hypothesis for this transcript/configuration only. It does
|
This verifies the hypothesis for this transcript/configuration only. It does
|
||||||
not claim the general chunker is fixed.
|
not claim the general chunker is fixed.
|
||||||
|
|
||||||
|
## BUG-013
|
||||||
|
|
||||||
|
ID: BUG-013
|
||||||
|
|
||||||
|
Title: Semantic Consolidator repetitive output loop exhausts generation budget
|
||||||
|
|
||||||
|
Pipeline stage: Semantic Consolidator
|
||||||
|
|
||||||
|
Severity: High
|
||||||
|
|
||||||
|
Status: Verified
|
||||||
|
|
||||||
|
Date discovered: 2026-08-09
|
||||||
|
|
||||||
|
Version first observed: `progeo_north_linux_20260809_124954`
|
||||||
|
|
||||||
|
Description:
|
||||||
|
|
||||||
|
The Semantic Consolidator entered a pathological generation loop that emitted
|
||||||
|
the same complete semantic group 192 consecutive times. The repeated group had
|
||||||
|
the stable structural signature consisting of the same `canonical_text`, the
|
||||||
|
same ordered `source_item_ids` (`fact_0004`, `fact_0019`) and the same
|
||||||
|
`merge_reason`. Generation then truncated at an incomplete
|
||||||
|
`"canonical_text":` field.
|
||||||
|
|
||||||
|
Expected behaviour:
|
||||||
|
|
||||||
|
The consolidator should detect a structural repeated-group loop
|
||||||
|
deterministically. Invalid JSON should receive at most one controlled retry
|
||||||
|
only when that loop signature is present. The retry must use the same model,
|
||||||
|
temperature and context configuration and add only an instruction preventing
|
||||||
|
duplicate group emission. Unrelated malformed JSON must not be retried.
|
||||||
|
|
||||||
|
Actual behaviour:
|
||||||
|
|
||||||
|
The first model response was invalid JSON at line 974, column 24, character
|
||||||
|
71184. Semantic Consolidator runtime was 277.910 seconds. The raw response had
|
||||||
|
71,184 characters (71,568 UTF-8 bytes), ended at `"canonical_text":`, and
|
||||||
|
could not reach deterministic source-coverage repair because it was not
|
||||||
|
parseable JSON.
|
||||||
|
|
||||||
|
Model metadata:
|
||||||
|
|
||||||
|
- Resolved `num_predict`: 19,532
|
||||||
|
- `num_ctx`: 32,768
|
||||||
|
- `prompt_eval_count`: 14,114
|
||||||
|
- `eval_count`: 18,654
|
||||||
|
- `done_reason`: `length`
|
||||||
|
- `eval_count` did not equal resolved `num_predict`; it exactly consumed the
|
||||||
|
remaining evaluated context (`32768 - 14114 = 18654`)
|
||||||
|
- Complete groups before truncation: 194
|
||||||
|
- Consecutive identical groups: 192, starting at group index 2
|
||||||
|
|
||||||
|
Root cause:
|
||||||
|
|
||||||
|
The model produced a degenerate repeated semantic-group sequence until the
|
||||||
|
remaining context budget was exhausted. BUG-010 adaptive response sizing was
|
||||||
|
working as designed; increasing `num_predict` would not address this failure
|
||||||
|
mode.
|
||||||
|
|
||||||
|
Implemented handling:
|
||||||
|
|
||||||
|
- Extract complete group objects from a response even when its outer JSON is
|
||||||
|
truncated.
|
||||||
|
- Identify groups by normalized `canonical_text`, ordered `source_item_ids`
|
||||||
|
and normalized `merge_reason`.
|
||||||
|
- Classify three or more consecutive identical complete groups as a loop; two
|
||||||
|
identical occurrences remain an ordinary duplicate handled by deterministic
|
||||||
|
coverage repair when the JSON is valid.
|
||||||
|
- Preserve first-attempt raw text, raw Ollama JSON and repetition metadata.
|
||||||
|
- Retry at most once only when JSON parsing fails and a loop is detected.
|
||||||
|
- Preserve separate retry artifacts and fail normally if retry parsing fails.
|
||||||
|
|
||||||
|
Related files:
|
||||||
|
|
||||||
|
- `samples/benchmarks/progeo_north_linux_20260809_124954/`
|
||||||
|
- `samples/benchmarks/progeo_north_linux_20260809_124954/semantic_consolidator/raw_model_response.txt`
|
||||||
|
- `samples/benchmarks/progeo_north_linux_20260809_124954/semantic_consolidator/raw_ollama_response.json`
|
||||||
|
- `src/meeting_lab/consolidation/consolidate_facts.py`
|
||||||
|
- `scripts/run_meeting.py`
|
||||||
|
- `tests/test_consolidate_facts.py`
|
||||||
|
|
||||||
|
Regression test available (yes/no): yes
|
||||||
|
|
||||||
|
Current status:
|
||||||
|
|
||||||
|
Verified for the repetitive-loop failure mode. A consolidator-only regression
|
||||||
|
used the preserved canonicalized input and did not run extraction,
|
||||||
|
canonicalization or rendering.
|
||||||
|
|
||||||
|
The first attempt reproduced the original failure signature: 71,184 response
|
||||||
|
characters, `eval_count=18654`, `done_reason=length`, invalid JSON at character
|
||||||
|
71184, and a detected 192-group consecutive repetition run. This triggered the
|
||||||
|
only permitted retry.
|
||||||
|
|
||||||
|
The retry used the same model and generation configuration, returned parseable
|
||||||
|
JSON after 7,362 evaluated tokens with `done_reason=stop`, and contained no
|
||||||
|
repetition loop (longest identical consecutive run: 1). Its source-coverage
|
||||||
|
validation before repair was invalid: 105 groups, 154 observed source-ID
|
||||||
|
occurrences, duplicate IDs, four missing IDs and one unknown ID. Deterministic
|
||||||
|
repair made 63 changes: 44 duplicate-ID removals, 15 empty-group removals and
|
||||||
|
four missing-ID singleton restorations. Validation after repair remained
|
||||||
|
invalid only because group 89 contained unknown `fact_0114`.
|
||||||
|
|
||||||
|
The stage therefore failed cleanly after the single retry and preserved both
|
||||||
|
attempts. No third LLM call occurred. The remaining unknown-ID failure is not a
|
||||||
|
repetitive generation loop and is not silently repaired by the existing
|
||||||
|
coverage repair. This verification does not claim that general Semantic
|
||||||
|
Consolidator output quality is solved.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ from src.meeting_lab.consolidation.canonicalize import ( # noqa: E402
|
|||||||
from src.meeting_lab.consolidation.consolidate_facts import ( # noqa: E402
|
from src.meeting_lab.consolidation.consolidate_facts import ( # noqa: E402
|
||||||
build_consolidated_output,
|
build_consolidated_output,
|
||||||
build_consolidation_prompt,
|
build_consolidation_prompt,
|
||||||
call_ollama as call_consolidator_ollama,
|
call_ollama_with_repetition_retry,
|
||||||
fact_items,
|
fact_items,
|
||||||
parse_model_json,
|
parse_model_json,
|
||||||
repair_model_group_coverage,
|
repair_model_group_coverage,
|
||||||
@@ -665,7 +665,7 @@ def run_semantic_consolidator_stage(
|
|||||||
prompt_token_estimate=prompt_token_estimate,
|
prompt_token_estimate=prompt_token_estimate,
|
||||||
num_ctx=num_ctx,
|
num_ctx=num_ctx,
|
||||||
)
|
)
|
||||||
raw_text, response_data, runtime = call_consolidator_ollama(
|
raw_text, response_data, runtime, attempts = call_ollama_with_repetition_retry(
|
||||||
endpoint=endpoint,
|
endpoint=endpoint,
|
||||||
model=model,
|
model=model,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
@@ -674,15 +674,11 @@ def run_semantic_consolidator_stage(
|
|||||||
num_predict=resolved_num_predict,
|
num_predict=resolved_num_predict,
|
||||||
think=False,
|
think=False,
|
||||||
progress_interval=progress_interval,
|
progress_interval=progress_interval,
|
||||||
|
output_dir=output_dir,
|
||||||
)
|
)
|
||||||
raw_response_path = output_dir / "raw_model_response.txt"
|
|
||||||
raw_json_path = output_dir / "raw_ollama_response.json"
|
|
||||||
output_path = output_dir / "consolidated_extractions.json"
|
output_path = output_dir / "consolidated_extractions.json"
|
||||||
report_path = output_dir / "report.md"
|
report_path = output_dir / "report.md"
|
||||||
repair_metadata_path = output_dir / "repair_metadata.json"
|
repair_metadata_path = output_dir / "repair_metadata.json"
|
||||||
raw_response_path.write_text(raw_text + "\n", encoding="utf-8")
|
|
||||||
write_json(raw_json_path, response_data)
|
|
||||||
|
|
||||||
model_output = parse_model_json(raw_text)
|
model_output = parse_model_json(raw_text)
|
||||||
expected_fact_ids = {item["item_id"] for item in facts}
|
expected_fact_ids = {item["item_id"] for item in facts}
|
||||||
validator_before = source_coverage_report_from_groups(
|
validator_before = source_coverage_report_from_groups(
|
||||||
@@ -734,9 +730,12 @@ def run_semantic_consolidator_stage(
|
|||||||
groups=groups,
|
groups=groups,
|
||||||
output_path=output_path,
|
output_path=output_path,
|
||||||
repair_changes=repair_changes,
|
repair_changes=repair_changes,
|
||||||
|
llm_call_count=len(attempts),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"runtime_seconds": runtime,
|
"runtime_seconds": runtime,
|
||||||
|
"llm_call_count": len(attempts),
|
||||||
|
"attempts": attempts,
|
||||||
"fact_count": len(facts),
|
"fact_count": len(facts),
|
||||||
"num_predict": resolved_num_predict,
|
"num_predict": resolved_num_predict,
|
||||||
"validator_before": validator_before,
|
"validator_before": validator_before,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
@@ -34,6 +35,15 @@ OUTPUT_TOKEN_ESTIMATE_CHARS = 4
|
|||||||
OUTPUT_GROUP_OVERHEAD_CHARS = 320
|
OUTPUT_GROUP_OVERHEAD_CHARS = 320
|
||||||
OUTPUT_SAFETY_MARGIN = 1.35
|
OUTPUT_SAFETY_MARGIN = 1.35
|
||||||
PROMPT_NAME = "consolidate_facts.md"
|
PROMPT_NAME = "consolidate_facts.md"
|
||||||
|
REPETITION_LOOP_MIN_CONSECUTIVE_GROUPS = 3
|
||||||
|
REPETITION_RETRY_INSTRUCTION = """
|
||||||
|
|
||||||
|
RETRY SAFETY INSTRUCTION:
|
||||||
|
Emit each semantic group exactly once. Before returning the JSON, check the
|
||||||
|
groups array for identical groups using canonical_text, source_item_ids and
|
||||||
|
merge_reason. If an identical group is already present, do not emit it again.
|
||||||
|
Finish the complete JSON object without repeating any group.
|
||||||
|
""".rstrip()
|
||||||
|
|
||||||
|
|
||||||
class ConsolidationValidationError(ValueError):
|
class ConsolidationValidationError(ValueError):
|
||||||
@@ -343,6 +353,169 @@ def parse_model_json(text: str) -> dict[str, Any]:
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def group_repetition_signature(group: dict[str, Any]) -> tuple[str, tuple[str, ...], str] | None:
|
||||||
|
canonical_text = group.get("canonical_text")
|
||||||
|
source_item_ids = group.get("source_item_ids")
|
||||||
|
merge_reason = group.get("merge_reason")
|
||||||
|
if (
|
||||||
|
not isinstance(canonical_text, str)
|
||||||
|
or not isinstance(source_item_ids, list)
|
||||||
|
or not all(isinstance(item_id, str) for item_id in source_item_ids)
|
||||||
|
or not isinstance(merge_reason, str)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
normalize = lambda value: re.sub(r"\s+", " ", value).strip()
|
||||||
|
return (
|
||||||
|
normalize(canonical_text),
|
||||||
|
tuple(source_item_ids),
|
||||||
|
normalize(merge_reason),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def completed_group_objects(text: str) -> list[dict[str, Any]]:
|
||||||
|
"""Extract complete JSON group objects even when the outer response is truncated."""
|
||||||
|
groups_key = re.search(r'"groups"\s*:\s*\[', text)
|
||||||
|
if groups_key is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
decoder = json.JSONDecoder()
|
||||||
|
groups: list[dict[str, Any]] = []
|
||||||
|
position = groups_key.end()
|
||||||
|
while position < len(text):
|
||||||
|
while position < len(text) and text[position] in " \t\r\n,":
|
||||||
|
position += 1
|
||||||
|
if position >= len(text) or text[position] == "]":
|
||||||
|
break
|
||||||
|
if text[position] != "{":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
value, position = decoder.raw_decode(text, position)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
break
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
break
|
||||||
|
groups.append(value)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def detect_repetitive_group_loop(
|
||||||
|
text: str,
|
||||||
|
minimum_consecutive: int = REPETITION_LOOP_MIN_CONSECUTIVE_GROUPS,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
groups = completed_group_objects(text)
|
||||||
|
longest_run = 0
|
||||||
|
longest_signature: tuple[str, tuple[str, ...], str] | None = None
|
||||||
|
run_start = 0
|
||||||
|
previous: tuple[str, tuple[str, ...], str] | None = None
|
||||||
|
current_run = 0
|
||||||
|
|
||||||
|
for index, group in enumerate(groups):
|
||||||
|
signature = group_repetition_signature(group)
|
||||||
|
if signature is not None and signature == previous:
|
||||||
|
current_run += 1
|
||||||
|
else:
|
||||||
|
current_run = 1 if signature is not None else 0
|
||||||
|
run_start = index
|
||||||
|
if current_run > longest_run:
|
||||||
|
longest_run = current_run
|
||||||
|
longest_signature = signature
|
||||||
|
longest_run_start = run_start
|
||||||
|
previous = signature
|
||||||
|
|
||||||
|
detected = longest_signature is not None and longest_run >= minimum_consecutive
|
||||||
|
signature_data = None
|
||||||
|
if longest_signature is not None:
|
||||||
|
signature_data = {
|
||||||
|
"canonical_text": longest_signature[0],
|
||||||
|
"source_item_ids": list(longest_signature[1]),
|
||||||
|
"merge_reason": longest_signature[2],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"detected": detected,
|
||||||
|
"threshold": minimum_consecutive,
|
||||||
|
"completed_group_count": len(groups),
|
||||||
|
"longest_consecutive_run": longest_run,
|
||||||
|
"run_start_group_index": longest_run_start if longest_run else None,
|
||||||
|
"signature": signature_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def call_ollama_with_repetition_retry(
|
||||||
|
*,
|
||||||
|
endpoint: str,
|
||||||
|
model: str,
|
||||||
|
prompt: str,
|
||||||
|
timeout: int,
|
||||||
|
num_ctx: int,
|
||||||
|
num_predict: int,
|
||||||
|
think: bool,
|
||||||
|
progress_interval: int,
|
||||||
|
output_dir: Path,
|
||||||
|
call_fn: Any = None,
|
||||||
|
) -> tuple[str, dict[str, Any], float, list[dict[str, Any]]]:
|
||||||
|
"""Make one call, retrying once only for invalid JSON with a detected group loop."""
|
||||||
|
if call_fn is None:
|
||||||
|
call_fn = call_ollama
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
attempts: list[dict[str, Any]] = []
|
||||||
|
total_runtime = 0.0
|
||||||
|
|
||||||
|
for attempt_number in (1, 2):
|
||||||
|
attempt_prompt = (
|
||||||
|
prompt
|
||||||
|
if attempt_number == 1
|
||||||
|
else f"{prompt}\n{REPETITION_RETRY_INSTRUCTION}\n"
|
||||||
|
)
|
||||||
|
raw_text, response_data, runtime = call_fn(
|
||||||
|
endpoint=endpoint,
|
||||||
|
model=model,
|
||||||
|
prompt=attempt_prompt,
|
||||||
|
timeout=timeout,
|
||||||
|
num_ctx=num_ctx,
|
||||||
|
num_predict=num_predict,
|
||||||
|
think=think,
|
||||||
|
progress_interval=progress_interval,
|
||||||
|
)
|
||||||
|
total_runtime += runtime
|
||||||
|
suffix = "" if attempt_number == 1 else "_retry"
|
||||||
|
(output_dir / f"raw_model_response{suffix}.txt").write_text(
|
||||||
|
raw_text + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
write_json(output_dir / f"raw_ollama_response{suffix}.json", response_data)
|
||||||
|
repetition = detect_repetitive_group_loop(raw_text)
|
||||||
|
attempt_metadata = {
|
||||||
|
"attempt": attempt_number,
|
||||||
|
"retry_instruction_added": attempt_number == 2,
|
||||||
|
"runtime_seconds": runtime,
|
||||||
|
"raw_response_chars": len(raw_text),
|
||||||
|
"raw_response_bytes": len(raw_text.encode("utf-8")),
|
||||||
|
"resolved_num_predict": num_predict,
|
||||||
|
"eval_count": response_data.get("eval_count"),
|
||||||
|
"done_reason": response_data.get("done_reason"),
|
||||||
|
"repetition": repetition,
|
||||||
|
}
|
||||||
|
attempts.append(attempt_metadata)
|
||||||
|
try:
|
||||||
|
parse_model_json(raw_text)
|
||||||
|
except ConsolidationValidationError as exc:
|
||||||
|
attempt_metadata["parse_error"] = str(exc)
|
||||||
|
write_json(
|
||||||
|
output_dir / "repetition_retry_metadata.json",
|
||||||
|
{"bug": "BUG-013", "attempts": attempts},
|
||||||
|
)
|
||||||
|
if attempt_number == 1 and repetition["detected"]:
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
write_json(
|
||||||
|
output_dir / "repetition_retry_metadata.json",
|
||||||
|
{"bug": "BUG-013", "attempts": attempts},
|
||||||
|
)
|
||||||
|
return raw_text, response_data, total_runtime, attempts
|
||||||
|
|
||||||
|
raise AssertionError("repetition retry loop exhausted unexpectedly")
|
||||||
|
|
||||||
|
|
||||||
def validate_model_groups(
|
def validate_model_groups(
|
||||||
model_output: dict[str, Any],
|
model_output: dict[str, Any],
|
||||||
expected_fact_ids: set[str],
|
expected_fact_ids: set[str],
|
||||||
@@ -626,6 +799,7 @@ def write_report(
|
|||||||
groups: list[dict[str, Any]],
|
groups: list[dict[str, Any]],
|
||||||
output_path: Path,
|
output_path: Path,
|
||||||
repair_changes: list[dict[str, Any]] | None = None,
|
repair_changes: list[dict[str, Any]] | None = None,
|
||||||
|
llm_call_count: int = 1,
|
||||||
) -> None:
|
) -> None:
|
||||||
merged = [group for group in groups if len(group["source_item_ids"]) > 1]
|
merged = [group for group in groups if len(group["source_item_ids"]) > 1]
|
||||||
singletons = [group for group in groups if len(group["source_item_ids"]) == 1]
|
singletons = [group for group in groups if len(group["source_item_ids"]) == 1]
|
||||||
@@ -634,7 +808,7 @@ def write_report(
|
|||||||
"",
|
"",
|
||||||
"- Scope: facts only",
|
"- Scope: facts only",
|
||||||
f"- Model: `{model}`",
|
f"- Model: `{model}`",
|
||||||
"- LLM call count: 1",
|
f"- LLM call count: {llm_call_count}",
|
||||||
f"- Runtime: {runtime:.3f} seconds",
|
f"- Runtime: {runtime:.3f} seconds",
|
||||||
f"- Fact item count: {fact_count}",
|
f"- Fact item count: {fact_count}",
|
||||||
f"- Prompt characters: {prompt_chars}",
|
f"- Prompt characters: {prompt_chars}",
|
||||||
@@ -693,10 +867,10 @@ def main() -> int:
|
|||||||
print(f"Estimated prompt size chars: {prompt_chars}")
|
print(f"Estimated prompt size chars: {prompt_chars}")
|
||||||
print(f"Estimated prompt tokens: {prompt_token_estimate}")
|
print(f"Estimated prompt tokens: {prompt_token_estimate}")
|
||||||
print(f"Resolved num_predict: {num_predict}")
|
print(f"Resolved num_predict: {num_predict}")
|
||||||
print("Expected LLM call count: 1")
|
print("Expected LLM call count: 1; at most 2 only after detected BUG-013 loop")
|
||||||
print("Expected runtime: 5-10 minutes on current local benchmark basis")
|
print("Expected runtime: 5-10 minutes on current local benchmark basis")
|
||||||
|
|
||||||
raw_text, _response_data, runtime = call_ollama(
|
raw_text, _response_data, runtime, attempts = call_ollama_with_repetition_retry(
|
||||||
endpoint=args.endpoint,
|
endpoint=args.endpoint,
|
||||||
model=args.model,
|
model=args.model,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
@@ -705,8 +879,8 @@ def main() -> int:
|
|||||||
num_predict=num_predict,
|
num_predict=num_predict,
|
||||||
think=args.think,
|
think=args.think,
|
||||||
progress_interval=args.progress_interval,
|
progress_interval=args.progress_interval,
|
||||||
|
output_dir=args.output_dir,
|
||||||
)
|
)
|
||||||
raw_response_path.write_text(raw_text + "\n", encoding="utf-8")
|
|
||||||
model_output = parse_model_json(raw_text)
|
model_output = parse_model_json(raw_text)
|
||||||
model_output, repair_changes = repair_model_group_coverage(model_output, facts)
|
model_output, repair_changes = repair_model_group_coverage(model_output, facts)
|
||||||
if repair_changes:
|
if repair_changes:
|
||||||
@@ -736,6 +910,7 @@ def main() -> int:
|
|||||||
groups=groups,
|
groups=groups,
|
||||||
output_path=output_path,
|
output_path=output_path,
|
||||||
repair_changes=repair_changes,
|
repair_changes=repair_changes,
|
||||||
|
llm_call_count=len(attempts),
|
||||||
)
|
)
|
||||||
except requests.ConnectionError as exc:
|
except requests.ConnectionError as exc:
|
||||||
print(f"Error: Ollama is not reachable at {args.endpoint}: {exc}", file=sys.stderr)
|
print(f"Error: Ollama is not reachable at {args.endpoint}: {exc}", file=sys.stderr)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import unittest
|
import unittest
|
||||||
import json
|
import json
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from src.meeting_lab.consolidation.consolidate_facts import (
|
from src.meeting_lab.consolidation.consolidate_facts import (
|
||||||
ConsolidationValidationError,
|
ConsolidationValidationError,
|
||||||
@@ -8,6 +10,8 @@ from src.meeting_lab.consolidation.consolidate_facts import (
|
|||||||
DEFAULT_NUM_PREDICT,
|
DEFAULT_NUM_PREDICT,
|
||||||
build_consolidated_output,
|
build_consolidated_output,
|
||||||
build_ollama_payload,
|
build_ollama_payload,
|
||||||
|
call_ollama_with_repetition_retry,
|
||||||
|
detect_repetitive_group_loop,
|
||||||
estimate_response_tokens,
|
estimate_response_tokens,
|
||||||
fact_items,
|
fact_items,
|
||||||
parse_model_json,
|
parse_model_json,
|
||||||
@@ -74,6 +78,14 @@ def canonicalized_fixture():
|
|||||||
|
|
||||||
|
|
||||||
class ConsolidateFactsTests(unittest.TestCase):
|
class ConsolidateFactsTests(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def group(text="A", ids=None, reason="Singleton."):
|
||||||
|
return {
|
||||||
|
"canonical_text": text,
|
||||||
|
"source_item_ids": ids or ["fact_0001"],
|
||||||
|
"merge_reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
def test_payload_construction_disables_streaming_and_thinking_by_default(self):
|
def test_payload_construction_disables_streaming_and_thinking_by_default(self):
|
||||||
payload = build_ollama_payload(
|
payload = build_ollama_payload(
|
||||||
model="qwen3.5:9B",
|
model="qwen3.5:9B",
|
||||||
@@ -174,6 +186,145 @@ class ConsolidateFactsTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(groups), 2)
|
self.assertEqual(len(groups), 2)
|
||||||
|
|
||||||
|
def test_valid_normal_consolidation_has_no_repetition_loop(self):
|
||||||
|
text = json.dumps(
|
||||||
|
{"groups": [self.group("A"), self.group("B", ["fact_0002"])]}
|
||||||
|
)
|
||||||
|
|
||||||
|
detection = detect_repetitive_group_loop(text)
|
||||||
|
|
||||||
|
self.assertFalse(detection["detected"])
|
||||||
|
self.assertEqual(detection["completed_group_count"], 2)
|
||||||
|
|
||||||
|
def test_ordinary_duplicate_group_is_not_a_repetition_loop(self):
|
||||||
|
duplicate = self.group()
|
||||||
|
text = json.dumps({"groups": [duplicate, duplicate]})
|
||||||
|
|
||||||
|
detection = detect_repetitive_group_loop(text)
|
||||||
|
|
||||||
|
self.assertFalse(detection["detected"])
|
||||||
|
self.assertEqual(detection["longest_consecutive_run"], 2)
|
||||||
|
|
||||||
|
def test_repetitive_identical_group_sequence_is_detected_structurally(self):
|
||||||
|
repeated = self.group("Same fact", ["fact_0001", "fact_0002"], "Equivalent.")
|
||||||
|
text = json.dumps({"groups": [repeated, repeated, repeated]})
|
||||||
|
|
||||||
|
detection = detect_repetitive_group_loop(text)
|
||||||
|
|
||||||
|
self.assertTrue(detection["detected"])
|
||||||
|
self.assertEqual(detection["longest_consecutive_run"], 3)
|
||||||
|
self.assertEqual(
|
||||||
|
detection["signature"],
|
||||||
|
{
|
||||||
|
"canonical_text": "Same fact",
|
||||||
|
"source_item_ids": ["fact_0001", "fact_0002"],
|
||||||
|
"merge_reason": "Equivalent.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_truncated_response_after_repetitive_loop_is_detected(self):
|
||||||
|
repeated = json.dumps(self.group(), separators=(",", ":"))
|
||||||
|
text = '{"groups":[' + ",".join([repeated] * 4) + ',{"canonical_text":'
|
||||||
|
|
||||||
|
detection = detect_repetitive_group_loop(text)
|
||||||
|
|
||||||
|
self.assertTrue(detection["detected"])
|
||||||
|
self.assertEqual(detection["completed_group_count"], 4)
|
||||||
|
|
||||||
|
def test_detected_invalid_loop_gets_one_controlled_retry(self):
|
||||||
|
repeated = json.dumps(self.group(), separators=(",", ":"))
|
||||||
|
truncated = '{"groups":[' + ",".join([repeated] * 3) + ',{"canonical_text":'
|
||||||
|
valid = json.dumps({"groups": [self.group()]})
|
||||||
|
call_fn = Mock(
|
||||||
|
side_effect=[
|
||||||
|
(truncated, {"eval_count": 100, "done_reason": "length"}, 1.0),
|
||||||
|
(valid, {"eval_count": 20, "done_reason": "stop"}, 0.5),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
raw_text, _data, runtime, attempts = call_ollama_with_repetition_retry(
|
||||||
|
endpoint="http://example.invalid",
|
||||||
|
model="test-model",
|
||||||
|
prompt="base prompt",
|
||||||
|
timeout=1,
|
||||||
|
num_ctx=1000,
|
||||||
|
num_predict=100,
|
||||||
|
think=False,
|
||||||
|
progress_interval=1,
|
||||||
|
output_dir=Path(directory),
|
||||||
|
call_fn=call_fn,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue((Path(directory) / "raw_model_response.txt").exists())
|
||||||
|
self.assertTrue((Path(directory) / "raw_model_response_retry.txt").exists())
|
||||||
|
metadata = json.loads(
|
||||||
|
(Path(directory) / "repetition_retry_metadata.json").read_text()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(raw_text, valid)
|
||||||
|
self.assertEqual(runtime, 1.5)
|
||||||
|
self.assertEqual(len(attempts), 2)
|
||||||
|
self.assertEqual(call_fn.call_count, 2)
|
||||||
|
self.assertEqual(metadata["attempts"][0]["repetition"]["detected"], True)
|
||||||
|
first_prompt = call_fn.call_args_list[0].kwargs["prompt"]
|
||||||
|
retry_prompt = call_fn.call_args_list[1].kwargs["prompt"]
|
||||||
|
self.assertEqual(first_prompt, "base prompt")
|
||||||
|
self.assertIn("Emit each semantic group exactly once", retry_prompt)
|
||||||
|
|
||||||
|
def test_unrelated_malformed_json_is_not_retried(self):
|
||||||
|
call_fn = Mock(return_value=("{invalid", {"done_reason": "length"}, 0.1))
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
with self.assertRaises(ConsolidationValidationError):
|
||||||
|
call_ollama_with_repetition_retry(
|
||||||
|
endpoint="http://example.invalid",
|
||||||
|
model="test-model",
|
||||||
|
prompt="prompt",
|
||||||
|
timeout=1,
|
||||||
|
num_ctx=1000,
|
||||||
|
num_predict=100,
|
||||||
|
think=False,
|
||||||
|
progress_interval=1,
|
||||||
|
output_dir=Path(directory),
|
||||||
|
call_fn=call_fn,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(call_fn.call_count, 1)
|
||||||
|
|
||||||
|
def test_retry_failure_preserves_both_attempts_and_raises(self):
|
||||||
|
repeated = json.dumps(self.group(), separators=(",", ":"))
|
||||||
|
truncated = '{"groups":[' + ",".join([repeated] * 3) + ',{"canonical_text":'
|
||||||
|
call_fn = Mock(
|
||||||
|
side_effect=[
|
||||||
|
(truncated, {"done_reason": "length"}, 1.0),
|
||||||
|
(truncated, {"done_reason": "length"}, 1.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
with self.assertRaises(ConsolidationValidationError):
|
||||||
|
call_ollama_with_repetition_retry(
|
||||||
|
endpoint="http://example.invalid",
|
||||||
|
model="test-model",
|
||||||
|
prompt="prompt",
|
||||||
|
timeout=1,
|
||||||
|
num_ctx=1000,
|
||||||
|
num_predict=100,
|
||||||
|
think=False,
|
||||||
|
progress_interval=1,
|
||||||
|
output_dir=root,
|
||||||
|
call_fn=call_fn,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue((root / "raw_model_response.txt").exists())
|
||||||
|
self.assertTrue((root / "raw_ollama_response.json").exists())
|
||||||
|
self.assertTrue((root / "raw_model_response_retry.txt").exists())
|
||||||
|
self.assertTrue((root / "raw_ollama_response_retry.json").exists())
|
||||||
|
metadata = json.loads((root / "repetition_retry_metadata.json").read_text())
|
||||||
|
|
||||||
|
self.assertEqual(call_fn.call_count, 2)
|
||||||
|
self.assertEqual(len(metadata["attempts"]), 2)
|
||||||
|
self.assertIn("parse_error", metadata["attempts"][1])
|
||||||
|
|
||||||
def test_no_missing_source_ids(self):
|
def test_no_missing_source_ids(self):
|
||||||
with self.assertRaisesRegex(ConsolidationValidationError, "Missing"):
|
with self.assertRaisesRegex(ConsolidationValidationError, "Missing"):
|
||||||
validate_model_groups(
|
validate_model_groups(
|
||||||
|
|||||||
Reference in New Issue
Block a user