thangvip commited on
Commit
f513720
·
verified ·
1 Parent(s): 00b0c78

fix: require literal practical support

Browse files

Avoid false past-claim rejections in reflective questions and require narration to address the concrete concern with an actionable step.

src/compliment_forest/orchestrator.py CHANGED
@@ -774,7 +774,11 @@ class ForestOrchestrator:
774
  )
775
  if "missing_practical_step" in content_issues[index]:
776
  reasons.append(
777
- "Give one small, specific, low-risk action framed as an option."
 
 
 
 
778
  )
779
  if index in repeated_sources:
780
  reasons.append(
@@ -831,8 +835,11 @@ class ForestOrchestrator:
831
  "Start over with a fresh five-chapter forest. The repaired draft still "
832
  "failed deterministic checks. Use plain, concrete language; mention "
833
  "the user's full concern only once; include realistic options in widen; "
834
- "give one small optional action in step; and end carry with a simple "
835
- f"plan. Rejections: {survivor_rejections}"
 
 
 
836
  )
837
  }
838
  forest = self._author(
 
774
  )
775
  if "missing_practical_step" in content_issues[index]:
776
  reasons.append(
777
+ "Replace the narration with one small, specific, low-risk action "
778
+ "about the user's concrete concern. Start with You could, Try, "
779
+ "or One option is, then use a practical verb such as ask, check, "
780
+ "compare, identify, list, practice, read, review, study, or write. "
781
+ "Do not use walking, breathing, or taking a step as the action."
782
  )
783
  if index in repeated_sources:
784
  reasons.append(
 
835
  "Start over with a fresh five-chapter forest. The repaired draft still "
836
  "failed deterministic checks. Use plain, concrete language; mention "
837
  "the user's full concern only once; include realistic options in widen; "
838
+ "make every narration discuss the actual concern rather than forest "
839
+ "movement; give one small optional action in step using ask, check, "
840
+ "compare, identify, list, practice, read, review, study, or write; and "
841
+ "end carry with a literal plan. Rejections: "
842
+ f"{survivor_rejections}"
843
  )
844
  }
845
  forest = self._author(
src/compliment_forest/prompts.py CHANGED
@@ -2,41 +2,280 @@ from __future__ import annotations
2
 
3
  import json
4
 
5
- AUTHOR_SYSTEM = """You are the author of The Compliment Forest.
6
- Return exactly one JSON object matching the supplied schema. Write warm, concise encouragement
7
- that names concrete details from the situation. Acknowledge difficulty without diagnosis,
8
- guarantees, hollow praise, or toxic positivity. Propose 3-6 distinct strengths and draft one
9
- clearing per strength. Spells are first-person, present-tense, and at most 12 words.
10
- The image_prompt describes only one friendly forest creature and its simple pose or setting."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  CRITIC_SYSTEM = """You are the quality gate for The Compliment Forest.
13
  Return exactly one JSON object with keep_indices, revise_indices, reasons, and optional scores.
14
- Judge situation-specificity, warmth, non-genericness, non-toxic-positivity, and redundancy.
15
- Keep the strongest 4-6 distinct clearings when the input supports them. Request revision only
16
- where one grounded rewrite can repair the draft. Never create new prose."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  def author_messages(
20
  name: str,
21
  situation: str,
22
  *,
 
23
  feedback: dict[str, str] | None = None,
24
  original: dict[str, object] | None = None,
 
25
  ) -> list[dict[str, str]]:
26
  request: dict[str, object] = {
27
  "name": name,
28
  "situation": situation,
 
 
29
  "schema": {
30
  "forest_title": "string",
31
  "proposed_strengths": ["3-6 distinct strings"],
32
  "clearings": [
33
  {
34
- "creature": "string",
35
- "strength": "string",
36
- "line": "specific grounded encouragement",
37
- "reflection": "agency-inviting question",
 
 
 
 
 
38
  "spell": "short first-person present-tense mantra",
39
- "image_prompt": "creature only, no style words or text",
40
  }
41
  ],
42
  },
@@ -55,29 +294,27 @@ def critic_messages(
55
  name: str,
56
  situation: str,
57
  forest: dict[str, object],
 
 
 
58
  ) -> list[dict[str, str]]:
 
 
59
  request = {
60
  "name": name,
61
  "situation": situation,
 
 
62
  "draft": forest,
 
63
  "output_schema": {
64
- "keep_indices": ["zero-based indices, max 6"],
65
- "revise_indices": ["subset needing one rewrite"],
66
- "reasons": {"index": "brief actionable reason"},
67
- "scores": [
68
- {
69
- "index": 0,
70
- "specificity": "1-5",
71
- "warmth": "1-5",
72
- "non_genericness": "1-5",
73
- "non_toxic_positivity": "1-5",
74
- "reason": "brief reason",
75
- }
76
- ],
77
  },
78
  }
79
  return [
80
  {"role": "system", "content": CRITIC_SYSTEM},
81
  {"role": "user", "content": json.dumps(request, ensure_ascii=False)},
82
  ]
83
-
 
2
 
3
  import json
4
 
5
+ PLANNER_SYSTEM = """You make a faithful evidence plan for The Compliment Forest.
6
+ Return exactly one JSON object matching the supplied schema. Use only facts stated in the
7
+ user's situation. Every fact anchor must copy an exact, contiguous source_phrase from the
8
+ situation. If the situation includes guided choices for personalization, treat them as
9
+ user-provided preferences for emotional focus, tone, and imagery, not as completed biography.
10
+ Return 1-4 fact_anchors objects, never strings. Every object must contain both source_phrase
11
+ and meaning. Do not copy schema descriptions into the output.
12
+ Do not add completed actions, memories, counts, dates, people, employers, places, interviews,
13
+ applications, relationships, or plans that the user did not state. A fear is an uncertainty,
14
+ not a fact. Keep the summary conservative and concise."""
15
+
16
+ AUTHOR_SYSTEM = """You are the storyteller of The Compliment Forest.
17
+ Return exactly one JSON object matching the supplied schema. Write a five-chapter walk in
18
+ second person that meets the user inside their worry and walks with them. Every chapter
19
+ must read as continuous narrative, not as cue cards or bullets.
20
+ The top-level key must be clearings, never chapters. proposed_strengths is required and
21
+ must list the distinct strength value from each clearing.
22
+ Never omit source_phrase or image_prompt from a clearing. Every spell must begin with
23
+ "I", "I am", or "I'm". Do not copy schema descriptions into the output.
24
+
25
+ Every chapter must include:
26
+ - scene_title: the name of where they are now (a place, a small figure, a moment). It
27
+ need not be an animal. Do not default every scene to an animal.
28
+ - scene_intro: one or two sentences that bridge the journey. Chapter 1 opens the journey
29
+ by acknowledging what the user is carrying. Chapters 2-5 must pick up from the previous
30
+ chapter's closing feeling or image.
31
+ - narration: two short paragraphs of second-person prose that help with the actual worry.
32
+ The encouragement is embedded inside the prose. Do not write a label or a list. Use
33
+ familiar, literal words. Keep most sentences to 20 words or fewer.
34
+ - strength: a short noun phrase naming what the chapter quietly honors in the user. It must
35
+ be unique across chapters and must echo something the user actually said or chose.
36
+ - reflection: one short question that helps the user notice, choose, or plan.
37
+ - spell: a first-person, present-tense mantra of at most 12 words.
38
+
39
+ Chapter arc roles and jobs in order:
40
+ - arrive: Name the feeling and concrete concern once. Do not solve it yet.
41
+ - steady: Separate what is known from what fear predicts. Do not dismiss the fear.
42
+ - widen: Offer two or three realistic options or ways to view the problem.
43
+ - step: Give one small, specific, low-risk action the user could try.
44
+ - carry: Summarize a simple plan or decision rule the user can remember.
45
+ The required order is arrive, steady, widen, step, carry.
46
+
47
+ Non-negotiable content rules:
48
+ - Keep forest movement and scenery out of narration. Put walking, trees, streams,
49
+ breathing, light, and symbolic action in scene_intro or image_prompt.
50
+ - Narration and reflection must discuss the user's actual concern in plain, literal words.
51
+ Every narration must use at least one concrete term from the situation.
52
+ - The step narration must name an action, not merely ask the user to take a step. Use an
53
+ optional frame plus a practical verb such as ask, check, compare, identify, list, practice,
54
+ read, review, study, or write. For a test-score worry, a valid kind of action is to review
55
+ one missed question and note which topic needs practice. Adapt the action to the situation;
56
+ do not copy this example when it does not fit.
57
+ - Carry must state a literal rule the user can remember, such as what to do when the worry
58
+ returns. Do not end only with calm, peace, light, a path, or a breath.
59
+
60
+ Advice in widen, step, and carry must preserve agency. Use "could", "might", "one option
61
+ is", or "consider" instead of commands. Make the action fit facts the user supplied. Do not
62
+ invent a resource, person, deadline, place, result, or past action. The goal is useful support,
63
+ not therapy language or a motivational speech.
64
+
65
+ The carry chapter closes the walk gently and is required. Give every chapter a distinct
66
+ sentence structure, emotional angle, scene title, narration, reflection, and spell. Do not
67
+ begin every chapter with the user's situation.
68
+ Do not quote or closely paraphrase the full situation more than once across the forest.
69
+ The source_phrase belongs in its metadata field;
70
+ do not force it into the narration. Avoid vague stock phrases about leaving space around a
71
+ worry, keeping your pace, staying with what is known, or an unsettled path. Always
72
+ silently repair spelling and grammar without calling attention to it. Acknowledge difficulty without
73
+ diagnosis, guarantees, hollow praise, or toxic positivity.
74
+ Never claim unsupported completed actions, memories, counts, dates, people, employers,
75
+ interviews, applications, relationships, or places. If the situation does
76
+ not state a past action, do not write "you have", "you did", "you asked", "you spoke", "you
77
+ sent", "you remember", or similar biography. Never turn a suggestion into present-tense
78
+ biography such as "you open", "you keep", "you say", or "you read". Frame an option with
79
+ "could", "might", "one option is", or a reflection question. Each chapter must copy one
80
+ exact source_phrase from the validated fact plan into the source_phrase field.
81
+ The forest_title must include the user's name.
82
+ Use the clarifying conversation when present to shape emotional focus, voice, and
83
+ imagery, but do not list the answers back at the user. The image_prompt describes one
84
+ coherent storybook scene; include no text, logo, artist name, or copyrighted character.
85
+
86
+ When revision_feedback is present, revise only the chapters named by numeric index.
87
+ Preserve every chapter that has no revision feedback exactly, field for field. If feedback
88
+ uses the key whole_forest, start over and write a fresh complete forest."""
89
 
90
  CRITIC_SYSTEM = """You are the quality gate for The Compliment Forest.
91
  Return exactly one JSON object with keep_indices, revise_indices, reasons, and optional scores.
92
+ Judge factual faithfulness, ordered story progression, situation-specificity, warmth,
93
+ non-genericness, non-toxic-positivity, narrative_continuity (does scene_intro bridge from the
94
+ previous chapter?), redundancy, abstract language, and practical usefulness. Revise stock
95
+ phrases about uncertainty, possibility, pace, or paths when they do not explain the user's
96
+ actual problem. The step chapter must contain a small practical next step framed as an option.
97
+ The widen chapter should offer realistic choices, and carry should leave a simple plan or
98
+ decision rule. Any claim not supported by the situation or validated fact plan must be revised.
99
+ Keep the required arrive, steady, widen, step, and carry roles
100
+ when they are faithful and distinct. Request revision only where one grounded rewrite can
101
+ repair the draft. Never create new prose. Refer to each chapter by its scene_title.
102
+ Do not put quotation marks inside reason strings. Use only indices listed in
103
+ valid_indices. Omit scores rather than returning an incomplete score object."""
104
+
105
+ INTAKE_SYSTEM = """You are interviewing the user about THEIR situation so the forest can
106
+ respond later with grounded, specific encouragement. Read the user's situation and every
107
+ prior question/answer. Produce exactly one JSON object matching the supplied schema.
108
+
109
+ The question must:
110
+ - Be one short sentence in second-person.
111
+ - Follow the request's focus_dimension and probe a NEW part of the situation.
112
+ - Never repeat or closely paraphrase any prior or rejected question.
113
+ - Echo or quote a concrete detail the user actually wrote so the question cannot feel
114
+ generic. If their situation is very short, ask about the feeling underneath rather
115
+ than fabricating facts.
116
+
117
+ Do NOT ask about the forest's tone, voice, art style, imagery, soundtrack, or how the
118
+ encouragement should sound. Those are picked separately by the user. Stay entirely
119
+ focused on understanding the user's problem.
120
+
121
+ Provide 3-4 short, distinct multiple-choice options. Each option must read as a
122
+ plausible answer the user themself might give, in their own emotional register — NOT
123
+ generic taxonomy labels like 'Anxious' or 'Hopeful'. Options should be specific to the
124
+ user's situation when possible.
125
+
126
+ Keep the entire response under 900 characters. Use normal JSON quotes, not
127
+ backslash-escaped quotes, and do not wrap the JSON object in a string. Set the trace
128
+ field to an empty string: "rationale": "". Stop immediately after the closing brace.
129
+
130
+ Do not repeat any prior question or option. Do not diagnose, advise, or guarantee
131
+ outcomes. Do not invent biography about the user."""
132
+
133
+ INTAKE_FOCUS_DIMENSIONS = (
134
+ "what specifically triggers or shapes the worry",
135
+ "what feels most at stake",
136
+ "when it feels harder or easier",
137
+ "what they have already tried or what support would help",
138
+ "what better or a small win would look like",
139
+ )
140
+
141
+ RECOVERY_INTAKE_QUESTIONS: tuple[dict[str, object], ...] = (
142
+ {
143
+ "question": "Which part of this feels loudest right now?",
144
+ "options": [
145
+ "What might go wrong",
146
+ "What other people will think",
147
+ "The unknown right after this",
148
+ ],
149
+ "rationale": "",
150
+ },
151
+ {
152
+ "question": "What feels most at stake for you here?",
153
+ "options": [
154
+ "How I see myself",
155
+ "How other people see me",
156
+ "A chance I do not want to lose",
157
+ ],
158
+ "rationale": "",
159
+ },
160
+ {
161
+ "question": "When does this feel hardest?",
162
+ "options": [
163
+ "When I have time alone with it",
164
+ "When I am around the people involved",
165
+ "Right before I have to act",
166
+ ],
167
+ "rationale": "",
168
+ },
169
+ {
170
+ "question": "What kind of support would help most right now?",
171
+ "options": [
172
+ "Someone listening without fixing it",
173
+ "A clearer next step",
174
+ "More time and room to think",
175
+ ],
176
+ "rationale": "",
177
+ },
178
+ {
179
+ "question": "What would a small win here look like?",
180
+ "options": [
181
+ "Just getting through it",
182
+ "Doing one part well",
183
+ "Feeling more honest about what I need",
184
+ ],
185
+ "rationale": "",
186
+ },
187
+ )
188
+
189
+
190
+ def intake_messages(
191
+ name: str,
192
+ situation: str,
193
+ history: list[dict[str, str]] | None = None,
194
+ *,
195
+ rejected_questions: list[str] | None = None,
196
+ seed: int = 3407,
197
+ ) -> list[dict[str, str]]:
198
+ history_payload = list(history or [])
199
+ focus_index = min(len(history_payload), len(INTAKE_FOCUS_DIMENSIONS) - 1)
200
+ request = {
201
+ "name": name,
202
+ "situation": situation,
203
+ "history": history_payload,
204
+ "rejected_questions": list(rejected_questions or []),
205
+ "focus_dimension": INTAKE_FOCUS_DIMENSIONS[focus_index],
206
+ "turn_index": len(history_payload),
207
+ "total_turns": 5,
208
+ "seed": seed,
209
+ "schema": {
210
+ "question": "one short second-person question",
211
+ "options": ["3-4 distinct short multiple-choice answers"],
212
+ "rationale": "optional short note for trace",
213
+ },
214
+ }
215
+ return [
216
+ {"role": "system", "content": INTAKE_SYSTEM},
217
+ {"role": "user", "content": json.dumps(request, ensure_ascii=False)},
218
+ ]
219
+
220
+
221
+ def planner_messages(
222
+ name: str,
223
+ situation: str,
224
+ *,
225
+ seed: int = 3407,
226
+ ) -> list[dict[str, str]]:
227
+ request = {
228
+ "name": name,
229
+ "situation": situation,
230
+ "seed": seed,
231
+ "schema": {
232
+ "faithful_summary": "conservative paraphrase using only stated facts",
233
+ "fact_anchors": [
234
+ {
235
+ "source_phrase": "exact contiguous text copied from situation",
236
+ "meaning": "conservative meaning of that phrase",
237
+ }
238
+ ],
239
+ "central_uncertainty": "what is not known or feared",
240
+ "desired_direction": "what the user appears to want, without guarantees",
241
+ },
242
+ }
243
+ return [
244
+ {"role": "system", "content": PLANNER_SYSTEM},
245
+ {"role": "user", "content": json.dumps(request, ensure_ascii=False)},
246
+ ]
247
 
248
 
249
  def author_messages(
250
  name: str,
251
  situation: str,
252
  *,
253
+ plan: dict[str, object],
254
  feedback: dict[str, str] | None = None,
255
  original: dict[str, object] | None = None,
256
+ seed: int = 3407,
257
  ) -> list[dict[str, str]]:
258
  request: dict[str, object] = {
259
  "name": name,
260
  "situation": situation,
261
+ "validated_fact_plan": plan,
262
+ "seed": seed,
263
  "schema": {
264
  "forest_title": "string",
265
  "proposed_strengths": ["3-6 distinct strings"],
266
  "clearings": [
267
  {
268
+ "arc_role": "arrive | steady | widen | step | carry",
269
+ "source_phrase": "exact source phrase from validated_fact_plan",
270
+ "scene_title": "short symbolic scene title (place, figure, or moment)",
271
+ "scene_intro": "one or two sentences bridging from the previous chapter",
272
+ "narration": (
273
+ "two short plain-language paragraphs performing the arc role's job"
274
+ ),
275
+ "strength": "short noun phrase the chapter quietly honors",
276
+ "reflection": "short question that helps the user notice, choose, or plan",
277
  "spell": "short first-person present-tense mantra",
278
+ "image_prompt": "one coherent scene, no style words or text",
279
  }
280
  ],
281
  },
 
294
  name: str,
295
  situation: str,
296
  forest: dict[str, object],
297
+ *,
298
+ plan: dict[str, object],
299
+ seed: int = 3407,
300
  ) -> list[dict[str, str]]:
301
+ clearings = forest.get("clearings")
302
+ clearing_count = len(clearings) if isinstance(clearings, list) else 0
303
  request = {
304
  "name": name,
305
  "situation": situation,
306
+ "validated_fact_plan": plan,
307
+ "seed": seed,
308
  "draft": forest,
309
+ "valid_indices": list(range(clearing_count)),
310
  "output_schema": {
311
+ "keep_indices": ["unique values from valid_indices"],
312
+ "revise_indices": ["subset of keep_indices needing one rewrite"],
313
+ "reasons": {"index": "brief actionable reason without quotation marks"},
314
+ "scores": [],
 
 
 
 
 
 
 
 
 
315
  },
316
  }
317
  return [
318
  {"role": "system", "content": CRITIC_SYSTEM},
319
  {"role": "user", "content": json.dumps(request, ensure_ascii=False)},
320
  ]
 
src/compliment_forest/quality.py CHANGED
@@ -1,8 +1,157 @@
1
  from __future__ import annotations
2
 
3
  import re
 
 
 
4
 
5
  _WORD_PATTERN = re.compile(r"[A-Za-z][A-Za-z'-]{2,}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  _STOP_WORDS = {
7
  "about",
8
  "after",
@@ -52,3 +201,182 @@ def groundedness_score(line: str, situation: str) -> int:
52
  def is_situation_grounded(line: str, situation: str) -> bool:
53
  return groundedness_score(line, situation) >= 1
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import re
4
+ from collections.abc import Sequence
5
+
6
+ from .schema import Clearing, SituationPlan
7
 
8
  _WORD_PATTERN = re.compile(r"[A-Za-z][A-Za-z'-]{2,}")
9
+ _DUPLICATE_WORD_PATTERN = re.compile(r"[A-Za-z][A-Za-z'-]*")
10
+ _DUPLICATE_FIELDS = ("scene_intro", "narration", "reflection", "spell")
11
+ _ARC_ORDER = ("arrive", "steady", "widen", "step", "carry")
12
+ _ABSTRACT_STOCK_PHRASES = (
13
+ "leave space around the worry",
14
+ "more than one future remains possible",
15
+ "the whole path is already settled",
16
+ "the whole path is settled",
17
+ "keep your own pace",
18
+ "return to what is known",
19
+ "stay with what is known",
20
+ "treating its forecast as a settled fact",
21
+ "what deserves attention now",
22
+ "when the worry grows loud",
23
+ )
24
+ _PRACTICAL_ACTION_PATTERN = re.compile(
25
+ r"\b(?:could|might|can|consider|try|one option is(?: to)?|"
26
+ r"(?:one|a) small step is(?: to)?)\b"
27
+ r"(?:\W+\w+){0,5}\W+"
28
+ r"(?:ask|break|check|choos(?:e|ing)|compar(?:e|ing)|contact|"
29
+ r"focus|identif(?:y|ying)|list|look|not(?:e|ing)|pick|practic(?:e|ing)|"
30
+ r"read|review|schedul(?:e|ing)|start|stud(?:y|ying)|talk|test|"
31
+ r"revisit|writ(?:e|ing))\b",
32
+ re.IGNORECASE,
33
+ )
34
+ _NUMBER_WORDS = {
35
+ "zero",
36
+ "two",
37
+ "three",
38
+ "four",
39
+ "five",
40
+ "six",
41
+ "seven",
42
+ "eight",
43
+ "nine",
44
+ "ten",
45
+ "eleven",
46
+ "twelve",
47
+ }
48
+ _DATE_WORDS = {
49
+ "monday",
50
+ "tuesday",
51
+ "wednesday",
52
+ "thursday",
53
+ "friday",
54
+ "saturday",
55
+ "sunday",
56
+ "january",
57
+ "february",
58
+ "march",
59
+ "april",
60
+ "june",
61
+ "july",
62
+ "august",
63
+ "september",
64
+ "october",
65
+ "november",
66
+ "december",
67
+ }
68
+ _UNSUPPORTED_DETAIL_PHRASES = {
69
+ "application",
70
+ "applications",
71
+ "cafe",
72
+ "coffee break",
73
+ "coffee breaks",
74
+ "company",
75
+ "cover letter",
76
+ "cover letters",
77
+ "hiring manager",
78
+ "hiring managers",
79
+ "interview",
80
+ "interviews",
81
+ "mentor",
82
+ "old boss",
83
+ "team meeting",
84
+ "team meetings",
85
+ }
86
+ _ACTION_FORMS = {
87
+ "apply": {"applied", "apply"},
88
+ "ask": {"asked", "ask"},
89
+ "book": {"booked", "book"},
90
+ "complete": {"completed", "complete"},
91
+ "draft": {"drafted", "draft"},
92
+ "finish": {"finished", "finish"},
93
+ "fix": {"fixed", "fix"},
94
+ "include": {"included", "include"},
95
+ "make": {"made", "make"},
96
+ "meet": {"met", "meet"},
97
+ "notice": {"noticed", "notice"},
98
+ "practice": {"practiced", "practised", "practice", "practise"},
99
+ "prepare": {"prepared", "prepare"},
100
+ "remember": {"remembered", "remember"},
101
+ "research": {"researched", "research"},
102
+ "revise": {"revised", "revise"},
103
+ "schedule": {"scheduled", "schedule"},
104
+ "send": {"sent", "send"},
105
+ "show": {"showed", "shown", "show"},
106
+ "speak": {"spoke", "spoken", "speak"},
107
+ "start": {"started", "start"},
108
+ "talk": {"talked", "talk"},
109
+ "write": {"wrote", "written", "write"},
110
+ }
111
+ _ACTION_LOOKUP = {form: root for root, forms in _ACTION_FORMS.items() for form in forms}
112
+ _PAST_ACTION_LOOKUP = {
113
+ form: root
114
+ for root, forms in _ACTION_FORMS.items()
115
+ for form in forms
116
+ if form not in {root, "practise"}
117
+ }
118
+ _PERFECT_CLAIM_PATTERN = re.compile(
119
+ r"\byou(?:'ve| have| had)\s+(?:already\s+)?"
120
+ r"(?P<verb>" + "|".join(sorted(_ACTION_LOOKUP, key=len, reverse=True)) + r")\b",
121
+ re.IGNORECASE,
122
+ )
123
+ _SIMPLE_PAST_CLAIM_PATTERN = re.compile(
124
+ r"\byou\s+(?P<verb>"
125
+ + "|".join(sorted(_PAST_ACTION_LOOKUP, key=len, reverse=True))
126
+ + r")\b",
127
+ re.IGNORECASE,
128
+ )
129
+ _DIRECT_CLAIM_PATTERN = re.compile(
130
+ r"(?:^|[.!?]\s+)[\"'“”]?\s*you\s+(?P<verb>[a-z][a-z'-]*)\b",
131
+ re.IGNORECASE,
132
+ )
133
+ _NON_BIOGRAPHICAL_YOU_VERBS = {
134
+ "are",
135
+ "can",
136
+ "could",
137
+ "deserve",
138
+ "do",
139
+ "don't",
140
+ "fear",
141
+ "feel",
142
+ "hope",
143
+ "know",
144
+ "matter",
145
+ "may",
146
+ "might",
147
+ "need",
148
+ "seem",
149
+ "should",
150
+ "want",
151
+ "wonder",
152
+ "would",
153
+ "worry",
154
+ }
155
  _STOP_WORDS = {
156
  "about",
157
  "after",
 
201
  def is_situation_grounded(line: str, situation: str) -> bool:
202
  return groundedness_score(line, situation) >= 1
203
 
204
+
205
+ def _normalized_phrase(text: str) -> str:
206
+ return " ".join(_DUPLICATE_WORD_PATTERN.findall(text.casefold()))
207
+
208
+
209
+ def source_phrase_in_situation(source_phrase: str, situation: str) -> bool:
210
+ source = _normalized_phrase(source_phrase)
211
+ return bool(source) and source in _normalized_phrase(situation)
212
+
213
+
214
+ def invalid_fact_anchor_indices(
215
+ plan: SituationPlan,
216
+ situation: str,
217
+ ) -> list[int]:
218
+ return [
219
+ index
220
+ for index, anchor in enumerate(plan.fact_anchors)
221
+ if not source_phrase_in_situation(anchor.source_phrase, situation)
222
+ ]
223
+
224
+
225
+ def _number_tokens(text: str) -> set[str]:
226
+ return {
227
+ token
228
+ for token in re.findall(r"\b(?:\d+|[a-z]+)\b", text.casefold())
229
+ if token.isdigit() or token in _NUMBER_WORDS
230
+ }
231
+
232
+
233
+ def _date_tokens(text: str) -> set[str]:
234
+ tokens = set(re.findall(r"\b[a-z]+\b", text.casefold()))
235
+ result = tokens & _DATE_WORDS
236
+ result.update(re.findall(r"\b\d{1,2}(?::\d{2})?\s*(?:a\.?m\.?|p\.?m\.?)\b", text.casefold()))
237
+ return result
238
+
239
+
240
+ def _action_roots(text: str) -> set[str]:
241
+ tokens = set(re.findall(r"\b[a-z]+\b", text.casefold()))
242
+ return {root for form, root in _ACTION_LOOKUP.items() if form in tokens}
243
+
244
+
245
+ def unsupported_specificity(text: str, situation: str) -> set[str]:
246
+ """Find concrete claims in generated prose that the user did not provide."""
247
+
248
+ issues: set[str] = set()
249
+ if _number_tokens(text) - _number_tokens(situation):
250
+ issues.add("invented_number")
251
+ if _date_tokens(text) - _date_tokens(situation):
252
+ issues.add("invented_date")
253
+
254
+ normalized_text = _normalized_phrase(text)
255
+ normalized_situation = _normalized_phrase(situation)
256
+ if any(
257
+ phrase in normalized_text and phrase not in normalized_situation
258
+ for phrase in _UNSUPPORTED_DETAIL_PHRASES
259
+ ):
260
+ issues.add("unsupported_detail")
261
+
262
+ situation_actions = _action_roots(situation)
263
+ past_claims = (
264
+ (_PERFECT_CLAIM_PATTERN, _ACTION_LOOKUP),
265
+ (_SIMPLE_PAST_CLAIM_PATTERN, _PAST_ACTION_LOOKUP),
266
+ )
267
+ for pattern, lookup in past_claims:
268
+ for match in pattern.finditer(text):
269
+ root = lookup[match.group("verb").casefold()]
270
+ if root not in situation_actions:
271
+ issues.add("unsupported_past_claim")
272
+ break
273
+ if "unsupported_past_claim" in issues:
274
+ break
275
+
276
+ situation_tokens = set(re.findall(r"\b[a-z][a-z'-]*\b", situation.casefold()))
277
+ for match in _DIRECT_CLAIM_PATTERN.finditer(text):
278
+ verb = match.group("verb").casefold()
279
+ if verb not in _NON_BIOGRAPHICAL_YOU_VERBS and verb not in situation_tokens:
280
+ issues.add("unsupported_direct_claim")
281
+ break
282
+ return issues
283
+
284
+
285
+ def content_quality_issues(clearing: Clearing) -> set[str]:
286
+ """Report stock abstraction and missing practical help in a clearing."""
287
+
288
+ issues: set[str] = set()
289
+ normalized = _normalized_phrase(
290
+ " ".join((clearing.scene_intro, clearing.narration, clearing.reflection))
291
+ )
292
+ if any(phrase in normalized for phrase in _ABSTRACT_STOCK_PHRASES):
293
+ issues.add("abstract_language")
294
+ if clearing.arc_role == "step" and (
295
+ "abstract_language" in issues
296
+ or not _PRACTICAL_ACTION_PATTERN.search(clearing.narration)
297
+ ):
298
+ issues.add("missing_practical_step")
299
+ return issues
300
+
301
+
302
+ def repeated_source_phrase_indices(
303
+ clearings: Sequence[Clearing],
304
+ *,
305
+ minimum_words: int = 5,
306
+ ) -> set[int]:
307
+ """Flag later narrations that repeat the same long source phrase."""
308
+
309
+ seen: set[str] = set()
310
+ repeated: set[int] = set()
311
+ for index, clearing in enumerate(clearings):
312
+ source = _normalized_phrase(clearing.source_phrase)
313
+ if len(source.split()) < minimum_words:
314
+ continue
315
+ if source not in _normalized_phrase(clearing.narration):
316
+ continue
317
+ if source in seen:
318
+ repeated.add(index)
319
+ seen.add(source)
320
+ return repeated
321
+
322
+
323
+ def valid_arc_indices(clearings: Sequence[Clearing]) -> list[int]:
324
+ """Return the first clearing for each arc role in narrative order."""
325
+
326
+ by_role: dict[str, int] = {}
327
+ for index, clearing in enumerate(clearings):
328
+ by_role.setdefault(clearing.arc_role, index)
329
+ return [by_role[role] for role in _ARC_ORDER if role in by_role]
330
+
331
+
332
+ def _normalized_tokens(text: str) -> set[str]:
333
+ return {token.casefold().strip("'") for token in _DUPLICATE_WORD_PATTERN.findall(text)}
334
+
335
+
336
+ def _token_containment(left: str, right: str) -> float:
337
+ left_tokens = _normalized_tokens(left)
338
+ right_tokens = _normalized_tokens(right)
339
+ if not left_tokens or not right_tokens:
340
+ return 0
341
+ return len(left_tokens & right_tokens) / min(len(left_tokens), len(right_tokens))
342
+
343
+
344
+ def duplicate_fields(
345
+ clearings: Sequence[Clearing],
346
+ indices: Sequence[int] | None = None,
347
+ *,
348
+ threshold: float = 0.78,
349
+ ) -> dict[int, list[str]]:
350
+ """Report fields in later clearings that substantially repeat an earlier one."""
351
+
352
+ candidate_indices = list(indices) if indices is not None else list(range(len(clearings)))
353
+ duplicates: dict[int, list[str]] = {}
354
+ for position, index in enumerate(candidate_indices):
355
+ for prior_index in candidate_indices[:position]:
356
+ for field in _DUPLICATE_FIELDS:
357
+ if field in duplicates.get(index, []):
358
+ continue
359
+ left = getattr(clearings[prior_index], field)
360
+ right = getattr(clearings[index], field)
361
+ if _token_containment(left, right) >= threshold:
362
+ duplicates.setdefault(index, []).append(field)
363
+ return duplicates
364
+
365
+
366
+ def distinct_indices(
367
+ clearings: Sequence[Clearing],
368
+ indices: Sequence[int],
369
+ *,
370
+ threshold: float = 0.78,
371
+ ) -> list[int]:
372
+ """Keep the earliest clearing from each group of repetitive prose."""
373
+
374
+ selected: list[int] = []
375
+ for index in indices:
376
+ if not duplicate_fields(
377
+ clearings,
378
+ [*selected, index],
379
+ threshold=threshold,
380
+ ).get(index):
381
+ selected.append(index)
382
+ return selected