A
File size: 7,079 Bytes
068faf9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ae2eb77
068faf9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ae2eb77
068faf9
 
ae2eb77
068faf9
 
 
 
 
 
 
 
 
 
ae2eb77
068faf9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
"""Per-session generator: produces the perfect-answer essay AND the
discussion plan in a single API call.

Called once at the moment the participant confirms their initial-argument
submission. Reads:
  - the participant's argument (passed in by app.py)
  - data/case_analysis.md (consolidated case material)
  - data/ethics-frameworks.md (analytical lenses)

Returns a dict {"perfect_answer": str, "discussion_plan": str}.

The prompt lives in `prompts/perfect_answer_prompt.md` and is loaded via
`core.config_loader.PERFECT_ANSWER_PROMPT`. To change the generator's
behaviour, edit that markdown file.

Discussion plan schema: each sub-probe has 5 labelled lines:
  - id: <int>
    component: <consequence-framework component name>
    gap: <one sentence on what is missing in the draft>
    rationale: <2-3 sentences on why the gap matters>
    hook: <participant-facing 1-2 sentence connector; no candidate answers>

Latency: around 15-30s with gpt-5.4-nano at medium reasoning. The host code
in app.py shows a "preparing the discussion" status message during the wait.
"""

import re
from core.config_loader import client as _default_client, MODEL, BASE_DIR, PERFECT_ANSWER_PROMPT


CASE_ANALYSIS_PATH = BASE_DIR / "data" / "case_analysis.md"
FRAMEWORKS_PATH = BASE_DIR / "data" / "ethics-frameworks.md"


USER_TEMPLATE = """=== PARTICIPANT'S DRAFT (for internal guidance only) ===
{argument}

=== CASE CONTENT (background; do not quote, do not list) ===
{case_analysis}

=== ANALYTICAL LENS (consequence framework) ===
{frameworks}

Now produce the three artifacts in the required format.
"""


# Tolerant marker matchers. The model occasionally decorates the markers
# (markdown bold/heading, extra spaces, varying '=' counts, case, or
# space/underscore/hyphen inside "DISCUSSION PLAN"). They must still be the
# only thing on their line. A too-strict regex here was silently dropping
# the discussion plan in earlier versions.
def _marker_re(label):
    return re.compile(
        r"^[ \t#*>_~`-]*=*[ \t]*" + label + r"[ \t]*=*[ \t#*>_~`-]*$",
        re.IGNORECASE | re.MULTILINE,
    )


_ESSAY_MK = _marker_re(r"ESSAY")
_DOMINANT_MK = _marker_re(r"DOMINANT[ \t_-]*FRAMEWORK")
_PLAN_MK = _marker_re(r"DISCUSSION[ \t_-]*PLAN")
_END_MK = _marker_re(r"END")

# Structural fallback: the plan body always opens with a numbered family
# header ("1) Family (...):") that is soon followed by a "- id:" line.
_PLAN_HEADER_RE = re.compile(r"^\s*\d+\s*[\).]\s+\S.*:\s*$", re.MULTILINE)
_ID_LINE_RE = re.compile(r"^\s*[-*]\s*id\s*:\s*\d+\s*$", re.IGNORECASE | re.MULTILINE)


def _span(mk, text, start=0):
    m = mk.search(text, start)
    return (m.start(), m.end()) if m else None


def _structural_plan_start(text, start=0):
    """Index of the first numbered family header that is followed (within
    ~600 chars) by an `id:` line, or None."""
    for hm in _PLAN_HEADER_RE.finditer(text, start):
        if _ID_LINE_RE.search(text[hm.start(): hm.start() + 600]):
            return hm.start()
    return None


def _parse_artifacts(raw):
    """Extract essay + dominant_framework + plan from the delimited response,
    tolerating decorated or missing markers. If the DISCUSSION PLAN marker is
    absent, the plan is recovered structurally from its numbered-family /
    id-line shape. The DOMINANT FRAMEWORK block (if present, between ESSAY
    and DISCUSSION PLAN) is extracted and STRIPPED from the essay so the
    participant-facing essay text does not leak the internal framework
    label."""
    raw = raw or ""

    e = _span(_ESSAY_MK, raw)
    body_start = e[1] if e else 0

    p = _span(_PLAN_MK, raw, body_start)
    end = _span(_END_MK, raw, p[1] if p else body_start)
    end_i = end[0] if end else len(raw)

    if p:
        essay = raw[body_start:p[0]].strip()
        plan = raw[p[1]:end_i].strip()
    else:
        split_at = _structural_plan_start(raw, body_start)
        if split_at is not None:
            essay = raw[body_start:split_at].strip()
            plan = raw[split_at:end_i].strip()
        else:
            essay = raw[body_start:end_i].strip()
            plan = ""

    # Extract DOMINANT FRAMEWORK from the essay region (it sits between
    # `=== ESSAY ===` and `=== DISCUSSION PLAN ===`) and strip it out so
    # the participant-facing essay text doesn't include the marker block.
    dominant = ""
    dm = _span(_DOMINANT_MK, essay)
    if dm:
        # essay text is everything before the DOMINANT marker.
        # dominant_framework value is everything after the marker line.
        dominant = essay[dm[1]:].strip()
        essay = essay[:dm[0]].strip()

    # Last-resort salvage: plan triples ended up inside the essay (marker
    # missed and the structural split landed too late).
    if not plan and _ID_LINE_RE.search(essay):
        s = _structural_plan_start(essay)
        if s is not None:
            plan = essay[s:].strip()
            essay = essay[:s].strip()

    return {"perfect_answer": essay, "discussion_plan": plan,
            "dominant_framework": dominant}


def generate_session_artifacts(argument_text, *, reasoning="medium", verbosity="medium", client=None):
    """Single blocking API call. Returns {"perfect_answer", "discussion_plan"}.
    Raises on API error."""
    api = client if client is not None else _default_client
    case = CASE_ANALYSIS_PATH.read_text(encoding="utf-8")
    frameworks = FRAMEWORKS_PATH.read_text(encoding="utf-8")

    user_msg = USER_TEMPLATE.format(
        argument=argument_text.strip(),
        case_analysis=case,
        frameworks=frameworks,
    )

    def _call():
        response = api.responses.create(
            model=MODEL,
            reasoning={"effort": reasoning},
            text={"verbosity": verbosity},
            input=[
                {"role": "developer", "content": PERFECT_ANSWER_PROMPT},
                {"role": "user", "content": user_msg},
            ],
        )
        return _parse_artifacts(response.output_text or "")

    arts = _call()
    # The discussion plan is mandatory: the host-side state machine is
    # disabled without it. The model occasionally drops the DISCUSSION
    # PLAN block (output budget exhaustion or model flakiness). Retry
    # up to 2 more times — 3 total attempts. Real production case:
    # session 20260601_195128_71512 had two consecutive empty attempts;
    # a third would almost certainly have caught it.
    MAX_RETRIES = 2
    for attempt in range(1, MAX_RETRIES + 1):
        if (arts.get("discussion_plan") or "").strip():
            break
        print(
            f"[perfect_answer] discussion_plan empty; retry {attempt}/{MAX_RETRIES}",
            flush=True,
        )
        retry = _call()
        if (retry.get("discussion_plan") or "").strip():
            arts = retry
            break
    return arts


# Backwards-compatible alias for any external test harness still importing
# the old name. Returns just the essay string.
def generate_perfect_answer(argument_text, **kwargs):
    return generate_session_artifacts(argument_text, **kwargs)["perfect_answer"]