204 lines
5.1 KiB
Python
204 lines
5.1 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Iterable, List, Tuple
|
|
|
|
__all__ = ["infer_language_from_prompt", "lint_response_markdown"]
|
|
|
|
|
|
_LANGUAGE_HINTS: List[Tuple[str, str]] = [
|
|
("swiftui", "swift"),
|
|
("swift", "swift"),
|
|
("objective-c", "objective-c"),
|
|
("objc", "objective-c"),
|
|
("kotlin", "kotlin"),
|
|
("typescript", "typescript"),
|
|
("javascript", "javascript"),
|
|
("node.js", "javascript"),
|
|
("python", "python"),
|
|
("rust", "rust"),
|
|
("go", "go"),
|
|
("c#", "csharp"),
|
|
("c++", "cpp"),
|
|
("java", "java"),
|
|
("sql", "sql"),
|
|
]
|
|
|
|
_CODE_SYMBOLS = set("{}();[]=<>+-*/.&|%!:@")
|
|
_CODE_PREFIXES = (
|
|
"func ",
|
|
"class ",
|
|
"struct ",
|
|
"enum ",
|
|
"protocol ",
|
|
"extension ",
|
|
"import ",
|
|
"let ",
|
|
"var ",
|
|
"guard ",
|
|
"if ",
|
|
"for ",
|
|
"while ",
|
|
"switch ",
|
|
"case ",
|
|
"return ",
|
|
"init(",
|
|
"init ",
|
|
"deinit",
|
|
"public ",
|
|
"private ",
|
|
"internal ",
|
|
"fileprivate ",
|
|
"override ",
|
|
"@",
|
|
"#if",
|
|
"#endif",
|
|
"#warning",
|
|
"#error",
|
|
)
|
|
|
|
_COMMENT_PREFIXES = ("///", "//", "/*", "*/", "* ")
|
|
_BULLET_PREFIXES = ("- ", "* ", "+ ", "• ")
|
|
|
|
|
|
def infer_language_from_prompt(prompt_text: str) -> str:
|
|
"""Infer a reasonable language hint from the prompt text."""
|
|
lowered = prompt_text.lower()
|
|
for token, language in _LANGUAGE_HINTS:
|
|
if token in lowered:
|
|
return language
|
|
return "text"
|
|
|
|
|
|
def lint_response_markdown(raw_text: str, *, language_hint: str = "text") -> str:
|
|
"""Normalize markdown so that only code segments are fenced and text stays plain."""
|
|
if not raw_text:
|
|
return ""
|
|
|
|
text = raw_text.replace("\r\n", "\n").replace("\r", "\n").strip("\n")
|
|
if not text:
|
|
return ""
|
|
|
|
if "```" in text:
|
|
return _normalize_existing_fences(text, language_hint)
|
|
|
|
return _auto_fence_code_segments(text, language_hint)
|
|
|
|
|
|
def _normalize_existing_fences(text: str, language_hint: str) -> str:
|
|
lines = text.split("\n")
|
|
output: List[str] = []
|
|
in_fence = False
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped.startswith("```"):
|
|
fence_marker = stripped[3:].strip()
|
|
if in_fence:
|
|
output.append("```")
|
|
in_fence = False
|
|
else:
|
|
language = fence_marker or (language_hint if language_hint != "text" else "")
|
|
output.append(f"```{language}" if language else "```")
|
|
in_fence = True
|
|
else:
|
|
output.append(line)
|
|
|
|
if in_fence:
|
|
output.append("```")
|
|
|
|
result = "\n".join(output).strip()
|
|
return result
|
|
|
|
|
|
def _auto_fence_code_segments(text: str, language_hint: str) -> str:
|
|
lines = text.split("\n")
|
|
segments: List[Tuple[str, List[str]]] = []
|
|
current_lines: List[str] = []
|
|
current_mode: str | None = None
|
|
|
|
for line in lines:
|
|
if _is_blank(line):
|
|
if current_lines:
|
|
current_lines.append(line)
|
|
continue
|
|
|
|
classification = "code" if _is_likely_code_line(line) else "text"
|
|
|
|
if current_mode is None:
|
|
current_mode = classification
|
|
current_lines.append(line)
|
|
continue
|
|
|
|
if classification == current_mode:
|
|
current_lines.append(line)
|
|
continue
|
|
|
|
segments.append((current_mode, current_lines))
|
|
current_mode = classification
|
|
current_lines = [line]
|
|
|
|
if current_lines:
|
|
segments.append((current_mode or "text", current_lines))
|
|
|
|
cleaned_parts: List[str] = []
|
|
for mode, block_lines in segments:
|
|
block_text = "\n".join(block_lines).strip("\n")
|
|
if not block_text:
|
|
continue
|
|
if mode == "code":
|
|
cleaned_parts.append(_format_code_block(block_text, language_hint))
|
|
else:
|
|
cleaned_parts.append(block_text)
|
|
|
|
return "\n\n".join(part for part in cleaned_parts if part).strip()
|
|
|
|
|
|
def _format_code_block(content: str, language_hint: str) -> str:
|
|
language = language_hint if language_hint and language_hint != "text" else ""
|
|
inner = content.strip("\n")
|
|
return f"```{language}\n{inner}\n```"
|
|
|
|
|
|
def _is_blank(line: str) -> bool:
|
|
return not line.strip()
|
|
|
|
|
|
def _is_likely_code_line(line: str) -> bool:
|
|
if line.startswith((" ", "\t")):
|
|
return True
|
|
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
return False
|
|
|
|
if stripped.startswith(_BULLET_PREFIXES):
|
|
return False
|
|
|
|
if stripped.startswith(_COMMENT_PREFIXES):
|
|
return True
|
|
|
|
if any(stripped.startswith(prefix) for prefix in _CODE_PREFIXES):
|
|
return True
|
|
|
|
if stripped.endswith(("{", "}", ";")):
|
|
return True
|
|
|
|
if stripped.startswith(("}", "{", "case ", "default:")):
|
|
return True
|
|
|
|
if "(" in stripped and ")" in stripped:
|
|
return True
|
|
|
|
if "=" in stripped:
|
|
return True
|
|
|
|
symbol_count = sum(1 for ch in stripped if ch in _CODE_SYMBOLS)
|
|
letter_count = sum(1 for ch in stripped if ch.isalpha())
|
|
if symbol_count >= 2 and symbol_count >= max(1, letter_count * 0.3):
|
|
return True
|
|
|
|
if re.search(r"\)\s*->", stripped):
|
|
return True
|
|
|
|
return False
|