File size: 11,632 Bytes
3bc3e37
9846f7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3bc3e37
 
b401a63
3bc3e37
 
 
 
b401a63
 
 
 
9846f7b
 
 
b401a63
 
 
9846f7b
 
 
b401a63
 
9846f7b
b401a63
 
 
 
 
 
 
 
 
 
 
9846f7b
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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
from app.llm.llm_client import LLMClient
from typing import List, Dict

SENTINEL = "</END>"

# Shared compact formatting contract applied to all personas.
COMPACT_MARKDOWN_V1 = (
    "You must format your answer using GitHub-Flavored Markdown and exactly these three sections in this order:\n"
    "### Thought\n"
    "- One sentence only.\n"
    "\n"
    "### What to do\n"
    "- Exactly 3 bullet points, one line each. Use '-' as the bullet. Do not use unicode bullets.\n"
    "- If you would use an ordered list, keep text on the same line as the number (e.g., '1. Do X').\n"
    "\n"
    "### Next step\n"
    "- One imperative sentence only.\n"
    "\n"
    "Rules: Use '###' for headings (never bold-as-heading). Insert a blank line between blocks. "
    "Do not include tables or code blocks unless explicitly requested. "
    "Do not include preambles or conclusions outside the three sections. "
    f"Finish your response with the sentinel token {SENTINEL}."
)

# Soft structure guidance per response_length
STRUCTURE_HINTS = {
    "short": "Keep it very concise: Thought as one short sentence; bullets ≤ 12 words; next step one short sentence.",
    "medium": "Be concise but clear: Thought one sentence; bullets ≤ 18 words; next step one sentence.",
    "long": "Provide slightly more detail while staying compact: Thought one sentence; bullets ≤ 24 words; next step one sentence.",
}

# Conservative token ceilings (kept close to prior behavior to avoid breaking changes)
MAX_TOKENS_MAP = {
    "short": 300,
    "medium": 500,
    "long": 800,
}

def _cut_at_sentinel(text: str) -> str:
    if not text:
        return ""
    idx = text.find(SENTINEL)
    return text[:idx] if idx != -1 else text

def _normalize_eols(text: str) -> str:
    return text.replace("\r\n", "\n").replace("\r", "\n")

def _rstrip_lines(text: str) -> str:
    return "\n".join(line.rstrip() for line in text.split("\n"))

def _convert_bold_headers_to_atx(lines: List[str]) -> List[str]:
    out = []
    for l in lines:
        # Full-line **Heading** or **Heading**: becomes '### Heading'
        # We keep only if the entire line is bold (plus optional colon) with no other text.
        import re
        m = re.match(r"^\s*\*\*(.+?)\*\*\s*:?\s*$", l)
        if m:
            out.append(f"### {m.group(1).strip()}")
        else:
            out.append(l)
    return out

def _convert_unicode_bullets(lines: List[str]) -> List[str]:
    out = []
    import re
    for l in lines:
        out.append(re.sub(r"^\s*[•●▪◦]\s+", "- ", l))
    return out

def _merge_orphan_numbered_items(lines: List[str]) -> List[str]:
    out = []
    i = 0
    import re
    while i < len(lines):
        cur = lines[i]
        m = re.match(r"^\s*(\d+)\.\s*$", cur)
        if m:
            # find next non-empty line and merge
            j = i + 1
            while j < len(lines) and lines[j].strip() == "":
                j += 1
            if j < len(lines):
                out.append(f"{m.group(1)}. {lines[j].strip()}")
                i = j + 1
                continue
        out.append(cur)
        i += 1
    return out

def _collapse_blank_runs(text: str) -> str:
    import re
    return re.sub(r"\n{3,}", "\n\n", text).strip()

def _truncate_words(s: str, limit: int) -> str:
    words = s.strip().split()
    if len(words) <= limit:
        return s.strip()
    return " ".join(words[:limit]) + "…"

def _first_sentence(text: str, max_words: int) -> str:
    import re
    # Split by sentence terminators conservatively
    parts = re.split(r"(?<=[\.!?])\s+", text.strip())
    first = parts[0] if parts else text.strip()
    return _truncate_words(first, max_words)

def _extract_heading_blocks(lines: List[str]) -> Dict[str, List[str]]:
    # Return mapping of 'ThoughtR', 'What to do', 'Next step' -> list of content lines
    sections = {"Thought": [], "What to do": [], "Next step": []}
    current = None
    for l in lines:
        if l.strip().lower().startswith("### thought"):
            current = "Thought"
            continue
        if l.strip().lower().startswith("### what to do"):
            current = "What to do"
            continue
        if l.strip().lower().startswith("### next step"):
            current = "Next step"
            continue
        if current:
            sections[current].append(l)
    return sections

def _extract_bullets(lines: List[str]) -> List[str]:
    bullets = []
    import re
    for l in lines:
        s = l.strip()
        if s.startswith("- "):
            bullets.append(s[2:].strip())
        elif s.startswith("* "):
            bullets.append(s[2:].strip())
        else:
            m = re.match(r"^(\d+)\.\s+(.*)$", s)
            if m and m.group(2).strip():
                bullets.append(m.group(2).strip())
    return bullets

def _synthesize_bullets_from_text(text: str, max_items: int, per_bullet_words: int) -> List[str]:
    # Fallback: split by sentences, make short bullet-like items
    import re
    sentences = re.split(r"(?<=[\.!?])\s+", text.strip())
    items = []
    for s in sentences:
        s_clean = s.strip("-•* ").strip()
        if not s_clean:
            continue
        items.append(_truncate_words(s_clean, per_bullet_words))
        if len(items) >= max_items:
            break
    if not items:
        return []
    return items[:max_items]

def _ensure_compact_shape(text: str, response_length: str) -> str:
    # Normalize and coerce into the 3-section compact shape.
    per_bullet_words = 12 if response_length == "short" else 18 if response_length == "medium" else 24
    sentence_words = 18 if response_length == "short" else 26 if response_length == "medium" else 34

    t = _cut_at_sentinel(_rstrip_lines(_normalize_eols(text)))
    lines = t.split("\n")
    lines = _convert_bold_headers_to_atx(lines)
    lines = _convert_unicode_bullets(lines)
    lines = _merge_orphan_numbered_items(lines)
    t = _collapse_blank_runs("\n".join(lines))
    lines = t.split("\n")

    sections = _extract_heading_blocks(lines)
    have_all = all(sections[k] for k in sections.keys())

    if not have_all:
        # Build compact output from scratch using best-effort extraction
        raw_plain = " ".join([l for l in lines if not l.strip().startswith("#")]).strip()
        tldr = _first_sentence(raw_plain, sentence_words) if raw_plain else ""
        # Try to pick bullets from any list-like lines first
        bullets = _extract_bullets(lines)
        if not bullets:
            bullets = _synthesize_bullets_from_text(raw_plain, 3, per_bullet_words)
        bullets = [ _truncate_words(b, per_bullet_words) for b in bullets[:3] ]
        # Next step heuristic: use next short imperative-like sentence, else reuse first bullet/action
        next_step = ""
        for cand in bullets:
            if cand:
                next_step = cand
                break
        if not next_step:
            next_step = tldr or "Proceed with the most actionable item."
        next_step = _truncate_words(next_step, sentence_words)

        parts = []
        parts.append("### Thought")
        parts.append(tldr or "Concise summary unavailable.")
        parts.append("")
        parts.append("### What to do")
        if bullets:
            for b in bullets:
                parts.append(f"- {b}")
        else:
            parts.append("- Identify the key task.")
            parts.append("- Decide the immediate next action.")
            parts.append("- Verify prerequisites and proceed.")
        parts.append("")
        parts.append("### Next step")
        parts.append(next_step)
        return "\n".join(parts).strip()

    # If sections exist, normalize their content and enforce caps
    tldr_body = " ".join([l.strip() for l in sections["Thought"] if l.strip()])
    tldr_final = _first_sentence(tldr_body, sentence_words) if tldr_body else "Concise summary unavailable."

    bullets = _extract_bullets(sections["What to do"])
    bullets = [ _truncate_words(b, per_bullet_words) for b in bullets[:3] ]
    if len(bullets) < 3:
        # try to synthesize remaining bullets from Thought or other content
        raw_plain = " ".join([l for l in lines if not l.strip().startswith("#")]).strip()
        filler = _synthesize_bullets_from_text(raw_plain, 3 - len(bullets), per_bullet_words)
        bullets.extend(filler)
    bullets = bullets[:3]

    next_body = " ".join([l.strip() for l in sections["Next step"] if l.strip()])
    if not next_body:
        next_body = bullets[0] if bullets else tldr_final
    next_final = _truncate_words(_first_sentence(next_body, sentence_words), sentence_words)

    parts = []
    parts.append("### Thought")
    parts.append(tldr_final)
    parts.append("")
    parts.append("### What to do")
    for b in bullets[:3]:
        parts.append(f"- {b}")
    parts.append("")
    parts.append("### Next step")
    parts.append(next_final)

    return "\n".join(parts).strip()

class Persona:
    def __init__(self, id: str, name: str, system_prompt: str, llm: LLMClient, temperature: int = 5):
        self.id = id
        self.name = name
        self.system_prompt = system_prompt
        self.llm = llm
        self.temperature = temperature

    async def respond(self, context: List[Dict], response_length: str = "medium") -> str:
        """Generate a compact, well-formed Markdown response suitable for the UI.
        Returns the compact Markdown string (backward compatible with previous callers).
        """
        max_tokens = MAX_TOKENS_MAP.get(response_length, 500)
        structure_hint = STRUCTURE_HINTS.get(response_length, STRUCTURE_HINTS["medium"])
        temp_scaled = round(self.temperature / 10, 2)

        full_prompt = (
            f"{self.system_prompt}\n\n"
            f"{COMPACT_MARKDOWN_V1}\n\n"
            f"{structure_hint}"
        )

        raw_text = await self.llm.generate(
            system_prompt=full_prompt,
            context=context,
            temperature=temp_scaled,
            max_tokens=max_tokens,
        )

        compact = _ensure_compact_shape(raw_text or "", response_length)

        # Final safety: cap extreme length by trimming bullet lines further if necessary
        # (We keep this conservative to avoid changing behavior unnecessarily)
        if len(compact) > 4000:  # very generous; UI should stay well below this
            # Trim bullets to even fewer words
            compact = _ensure_compact_shape(compact, "short")

        return compact


"""from app.llm.llm_client import LLMClient

class Persona:
    def __init__(self, id, name, system_prompt, llm, temperature=5):
        self.id = id
        self.name = name
        self.system_prompt = system_prompt
        self.llm = llm
        self.temperature = temperature
    
    async def respond(self, context: list[dict], response_length: str = "medium") -> str:
        max_tokens_map = {
            "short": 300,
            "medium": 500,
            "long": 800
        }

        response_style_map = {
            "short": "Respond in 20-30 words.",
            "medium": "Respond in 40-50 words.",
            "long": "Respond in 50-60 words."
        }

        max_tokens = max_tokens_map.get(response_length, 500)
        response_instruction = response_style_map.get(response_length, "medium")
        temp_scaled = round(self.temperature / 10, 2)

        full_prompt = f"{self.system_prompt}\n\n{response_instruction}"

        return await self.llm.generate(
            system_prompt=full_prompt,
            context=context,
            temperature=temp_scaled,
            max_tokens=max_tokens
        )
"""