diff --git a/atelier/provider.py b/atelier/provider.py index 113d2fe..434dd0d 100644 --- a/atelier/provider.py +++ b/atelier/provider.py @@ -21,12 +21,13 @@ def literal_checks(original, draft): patterns = { 'Platzhalter': r'\{\{[^{}\n]+\}\}', 'Port': r'(?i)\bport\s*[:=]?\s*(\d{1,5})\b|:(\d{2,5})\b', - 'Pfad': r'(? 20: raise ValueError() @@ -184,13 +192,18 @@ Return ONLY JSON: {"issues": [{"description": "brief German explanation", "origi raise ValueError() if not issue['description'].strip(): raise ValueError() - if issue['original_quote'] and issue['original_quote'] not in original: - raise ValueError() - if issue['draft_quote'] and issue['draft_quote'] not in draft: - raise ValueError() - return [{k: i[k] for k in ('description', 'original_quote', 'draft_quote')} for i in issues] except (ValueError, KeyError, TypeError): - raise self.invalid_response('InvalidReviewSchemaOrQuotes', 'Die KI-Prüfausgabe hat ein ungültiges Format oder enthält nicht belegbare Zitate. Die Selbstprüfung ist unvollständig.') from None + raise self.invalid_response('ReviewInvalidSchema', 'Die Prüfausgabe enthält nicht die erwartete Liste mit Befunden und Zitaten.') from None + for issue in issues: + for key, source in [('original_quote', original), ('draft_quote', draft)]: + quote = issue[key] + if quote and quote not in source: + # Line wrapping is not a content change. Keep the actual source excerpt. + match = re.search(r'\s+'.join(re.escape(w) for w in quote.split()), source) if quote.strip() else None + if not match: + raise self.invalid_response('ReviewQuoteMismatch', 'Das Modell nennt ein Zitat, das im zugehörigen Text nicht vorkommt. Die Selbstprüfung ist deshalb nicht abgeschlossen.') from None + issue[key] = match.group(0) + return [{k: i[k] for k in ('description', 'original_quote', 'draft_quote')} for i in issues] async def chat_text(self, system, payload): data = await self.request('/chat/completions', { diff --git a/tests/test_review.py b/tests/test_review.py index 647a36b..dff7903 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -112,3 +112,28 @@ def test_failed_final_review_labels_corrected_draft(): r,_=run([BAD,json.dumps({'issues':ISSUES}),ORIGINAL,503]) assert r['review']['draft_kind']=='corrected' assert r['review']['failed_stage']=='final_review' + + +def test_markdown_port_and_prose_slash_regression(): + from atelier.provider import literal_checks + assert literal_checks('Use port 8099 for retro games.', 'Use **port** **8099** for 8-bit/retro games.') == [] + assert literal_checks('port 8099', 'port **8080**') + assert literal_checks('at /app/data', 'at /app/new') + + +def test_fenced_review_json_is_accepted(): + r,_=run([ORIGINAL,'```json\n{"issues": []}\n```']) + assert r['review']['status']=='passed' + + +def test_quote_wrapping_does_not_fail_validation(): + issues=[{'description':'Zeit verändert','original_quote':'There is no\ntime limit.','draft_quote':'Time limit: 5 hours.'}] + r,_=run([BAD,json.dumps({'issues':issues}),ORIGINAL,'{"issues":[]}']) + assert r['review']['status']=='corrected' + + +@pytest.mark.parametrize('output,code', [('oops','ReviewInvalidJSON'),('{"issues":false}','ReviewInvalidSchema'),('{"issues":[{"description":"Problem","original_quote":"fabricated","draft_quote":""}]}','ReviewQuoteMismatch')]) +def test_review_errors_distinguishable(output,code,caplog): + r,_=run([BAD,output]) + assert r['review']['status']=='unchecked' + assert code in caplog.text