Sohan Kshirsagar
Response formatting fix
e5d716d
Raw
History Blame Contribute Delete
11.6 kB
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
)
"""