"""Paste input → a single-conversation ParsedExport (source="paste"). Mirrors the backend `prompt_card.adapters.paste` (the reuse target) and adds markdown variants. Accepts: - role markers at line start: User:/You:/Human:/Me:/Prompt:/Q: and Assistant:/AI:/ChatGPT:/Claude:/GPT:/Bot:/Gemini:/Answer:/Response:/A: - separators `:` `>` `-` (en-dash too), e.g. "User -" / "AI >" - markdown: **User**:, __Assistant__:, "## Assistant", "> User:", "- User:" - plain alternating turns (blank-line-separated blocks) → user/assistant starting with user Requires >=2 user turns (same substance bar as the export path) so all five axes can show signal. Single conversation only; we never fetch share URLs (paste text only — legal safety). """ from __future__ import annotations import re from .parser import Conversation, Turn, ParsedExport, ParseError _USER_WORDS = {"user", "you", "human", "me", "prompt", "q"} _ASST_WORDS = {"assistant", "ai", "chatgpt", "chat gpt", "gpt", "claude", "bot", "gemini", "model", "answer", "response", "a"} # line -> (role_word, separator?, rest). Strips leading markdown (#, >, -, *, spaces) and bold/italic. _LINE = re.compile(r"^[\s>#\-]*[*_]{0,3}\s*([A-Za-z][A-Za-z ]{0,11})\s*[*_]{0,3}\s*([:>–-])?\s*(.*)$") def _marker(line: str): """Return (role, rest_text) if the line opens a turn, else None.""" stripped_lead = re.sub(r"^[\s>#\-]+", "", line) had_md = (stripped_lead != line.lstrip()) or "*" in line[:3] or "_" in line[:3] m = _LINE.match(line) if not m: return None word, sep, rest = m.group(1).strip().lower(), m.group(2), m.group(3) if not (sep or had_md): # a bare word with no separator/markdown is prose, not a marker return None if word in _USER_WORDS: return "user", rest if word in _ASST_WORDS: return "assistant", rest return None def _validate(turns: list[Turn]) -> list[Conversation]: user_n = sum(1 for t in turns if t.role == "user") if user_n < 2: raise ParseError("Paste a conversation with at least 2 of YOUR messages " "(use 'User:' / 'Assistant:' markers, or blank lines between turns).") return [Conversation(turns=turns)] def parse_paste(text: str) -> ParsedExport: if not isinstance(text, str) or not text.strip(): raise ParseError("Nothing to analyze — paste a conversation first.") marked, role, buf = [], None, [] def flush(): if role and buf: t = "\n".join(buf).strip() if t: marked.append(Turn(role=role, text=t)) for ln in text.splitlines(): hit = _marker(ln) if hit: flush() role, buf = hit[0], ([hit[1]] if hit[1] else []) elif role is not None: buf.append(ln) flush() if marked: return ParsedExport(source="paste", conversations=_validate(marked)) # no markers: blank-line blocks, alternating user/assistant starting with the user blocks = [b.strip() for b in re.split(r"\n\s*\n", text) if b.strip()] if len(blocks) < 2: blocks = [ln.strip() for ln in text.splitlines() if ln.strip()] roles = ("user", "assistant") turns = [Turn(role=roles[i % 2], text=b) for i, b in enumerate(blocks)] return ParsedExport(source="paste", conversations=_validate(turns))