diff --git a/backend/llm.py b/backend/llm.py index c111beb..548ab4b 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -54,26 +54,39 @@ def _extract_message(response) -> tuple[str, str]: return content, thinking -async def call_ollama(prompt: str, *, tag: str = "default", temperature: float = 0.7, thinking: str = None) -> dict: +async def call_ollama( + prompt: str, + *, + system_prompt: str = None, + tag: str = "default", + temperature: float = 0.7, + thinking: str = None, +) -> dict: """ 调用 Ollama API 并返回 content 和 thinking。 """ start = time.perf_counter() start_dt = datetime.now() logger.info( - "[LLM][%s] request model=%s host=%s prompt_chars=%d temp=%.2f thinking=%s", + "[LLM][%s] request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s", tag, OLLAMA_MODEL, OLLAMA_HOST, len(prompt), + len(system_prompt or ""), temperature, thinking, ) try: + messages = [] + if system_prompt and system_prompt.strip(): + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + kwargs = { "model": OLLAMA_MODEL, - "messages": [{'role': 'user', 'content': prompt}], + "messages": messages, "stream": False, "options": { 'temperature': temperature, diff --git a/backend/main.py b/backend/main.py index 6b630af..3803ff6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,7 +8,7 @@ import base64 import uuid import logging -from prompt import build_prompt, prepare_prompt_context +from prompt import build_completion_prompts, prepare_prompt_context from llm import call_ollama, call_vlm_ocr from geoip import get_ip_location_text @@ -98,7 +98,7 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s logger.info("[%s] llm_input_prefix=%r", request_id, llm_prefix) logger.info("[%s] llm_input_suffix=%r", request_id, llm_suffix) - prompt = build_prompt( + system_prompt, user_prompt = build_completion_prompts( req.prefix, req.suffix, req.languageId, @@ -107,7 +107,8 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s preferences=req.user_preferences ) result = await call_ollama( - prompt, + user_prompt, + system_prompt=system_prompt, tag=f"{request_id}-primary", temperature=0.7, thinking=req.model_thinking if req.model_thinking != "none" else None diff --git a/backend/prompt.py b/backend/prompt.py index 10ed6db..17cba2c 100644 --- a/backend/prompt.py +++ b/backend/prompt.py @@ -1,27 +1,40 @@ +from datetime import datetime, timedelta, timezone +import re from typing import Tuple -from datetime import datetime, timezone, timedelta + def _get_current_datetime(timezone_pref: str = "auto") -> str: - # Default to UTC+8 if auto or not specified + # Default to UTC+8 if auto or not specified. offset = 8 tz_info = " (UTC+8)" - - if timezone_pref and timezone_pref != 'auto': - # Try to parse something like "UTC+8" or "GMT+8" - import re - match = re.search(r'([+-])(\d+)', timezone_pref) + + if timezone_pref and timezone_pref != "auto": + # Parse values like "UTC+8" or "GMT-5". + match = re.search(r"([+-])(\d+)", timezone_pref) if match: sign = match.group(1) hours = int(match.group(2)) - offset = hours if sign == '+' else -hours + offset = hours if sign == "+" else -hours tz_info = f" ({timezone_pref})" else: tz_info = f" ({timezone_pref})" now = datetime.now(timezone(timedelta(hours=offset))) - weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] + weekdays = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ] weekday = weekdays[now.weekday()] - return f"{now.year}年{now.month}月{now.day}日 {weekday} {now.hour:02d}:{now.minute:02d}:{now.second:02d}{tz_info}" + return ( + f"{now.year}-{now.month:02d}-{now.day:02d} " + f"{weekday} {now.hour:02d}:{now.minute:02d}:{now.second:02d}{tz_info}" + ) + def _sanitize_language_id(language_id: str) -> str: if not language_id: @@ -34,98 +47,247 @@ def _sanitize_language_id(language_id: str) -> str: return value or "markdown" +def _normalize_newlines(text: str) -> str: + return (text or "").replace("\r\n", "\n").replace("\r", "\n") + + def _prepare_context(prefix: str, suffix: str) -> Tuple[str, str]: """ Prepare prefix/suffix for model completion context. - Filter out potential web-scraping or legacy artifacts like
,
, . + Filter out potential web-scraping or legacy artifacts like
,
, . """ - import re - br_pattern = re.compile(r'', re.IGNORECASE) - clean_prefix = br_pattern.sub('', prefix or "") - clean_suffix = br_pattern.sub('', suffix or "") + br_pattern = re.compile(r"", re.IGNORECASE) + clean_prefix = br_pattern.sub("", prefix or "") + clean_suffix = br_pattern.sub("", suffix or "") return clean_prefix, clean_suffix +FENCE_LINE_RE = re.compile(r"^[ \t]*```.*$") + + +def _cursor_in_fenced_code_block(prefix: str) -> bool: + """ + Determine whether the cursor is currently inside a fenced code block. + The state is computed by toggling on each markdown fence line that matches: + ^[ \t]*```.*$ + """ + normalized = _normalize_newlines(prefix) + in_fence = False + for line in normalized.split("\n"): + if FENCE_LINE_RE.match(line): + in_fence = not in_fence + return in_fence + + def prepare_prompt_context(prefix: str, suffix: str) -> Tuple[str, str]: return _prepare_context(prefix, suffix) -def build_prompt( - prefix: str, - suffix: str, - language_id: str = "markdown", +def build_inline_system_prompt(language_id: str = "markdown") -> str: + safe_language_id = _sanitize_language_id(language_id) + system_prompt = f"""You are an inline completion engine for a {safe_language_id} editor with ghost-text suggestions. + +Return only the insertion text that should be placed between PREFIX and SUFFIX. + +Hard constraints you must follow: +1) Output-only contract: +- Output insertion text only. +- No explanations, no meta labels, no wrapper quotes around the whole answer. + +2) Strict math formatting (KaTeX): +- If you output any math expression, it must be strict KaTeX-compatible math. +- Every formula must be wrapped with either $...$ (inline) or $$...$$ (block). +- Never output bare formulas without $ or $$ wrappers. + +3) Strict code formatting: +- Read CURSOR_IN_FENCED_CODE_BLOCK from the user prompt. +- If CURSOR_IN_FENCED_CODE_BLOCK=true: + - You are already inside a fenced code block. + - Never output triple backticks. + - Output code lines only. +- If CURSOR_IN_FENCED_CODE_BLOCK=false: + - Any code output must be in a fenced code block with a language tag: + ```{{language}} + ... + ``` + - Do not output code snippets as inline backticks. + - Choose the language tag from context (no default fallback tag instruction). + +4) Boundary newline repair: +- Read PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE from the user prompt. +- Carefully reason about whether OUTPUT should start or end with a newline. +- If PREFIX lacks a required boundary newline, add it at OUTPUT start. +- If SUFFIX lacks a required boundary newline, add it at OUTPUT end. +- Ensure PREFIX + OUTPUT + SUFFIX is structurally natural. + +5) Context stitching: +- Do not repeat text that already appears at the start of SUFFIX. +- Preserve nearby language, tone, punctuation, indentation, and markdown structure. +- Continue existing structures naturally (lists, tables, block quotes, headings). + +6) OCR safety: +- PREFIX may include hidden OCR metadata tags like . +- Never output any OCR tag. +- Never output strings containing as OCR artifacts.""" + return system_prompt.strip() + + +INLINE_EXAMPLES = """[EX01] Prose continuation +The quick brown fox +jumps over the lazy dog. +Expected OUTPUT: +moved quietly and then + +[EX02] Avoid repeating suffix beginning +Our launch plan starts with +phase one, followed by phase two. +Expected OUTPUT: +careful internal testing before + +[EX03] Continue markdown checklist +## TODO +- [ ] Buy milk +- [ ] + +Expected OUTPUT: +Write release notes and share draft with team + +[EX04] Cursor outside code block, code must use fenced block +CURSOR_IN_FENCED_CODE_BLOCK=false +Parse this JSON payload in Python: + +Expected OUTPUT: +```python +import json +data = json.loads(payload) +``` + +[EX05] Cursor inside fenced code block, do not output fences +CURSOR_IN_FENCED_CODE_BLOCK=true +```python +def add(a, b): + return + +``` +Expected OUTPUT: +a + b + +[EX06] Inline math must use $...$ +The derivative of x^2 is +. +Expected OUTPUT: +$2x$ + +[EX07] Block math must use $$...$$ +We can write the Gaussian integral as: + +Expected OUTPUT: +$$ +\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi} +$$ + +[EX08] Prefix misses boundary newline; add newline at output start +PREFIX_ENDS_WITH_NEWLINE=false +Deployment steps: + +Expected OUTPUT: + +- Build artifact +- Deploy service + +[EX09] Suffix misses boundary newline; add newline at output end +SUFFIX_STARTS_WITH_NEWLINE=false +Summary paragraph complete. +## Next Section +Expected OUTPUT: + + +[EX10] OCR metadata exists but must never be emitted +![whiteboard](img.png) +The relationship is +. +Expected OUTPUT: +$y = mx + b$ + +[EX11] Continue markdown table with correct row shape +| Name | Score | +| --- | --- | +| Alice | 92 | +| Bob | + +Expected OUTPUT: +88 | + +[EX12] Mixed text + math + code in one insertion +CURSOR_IN_FENCED_CODE_BLOCK=false +Use the area formula and provide a tiny JS helper. + +Expected OUTPUT: +The area is $A = \\pi r^2$. + +```javascript +const area = (r) => Math.PI * r * r; +```""" + + +def build_completion_prompts( + prefix: str, + suffix: str, + language_id: str = "markdown", location: str = "", thinking_level: str = "low", - preferences: object = None -) -> str: + preferences: object = None, +) -> Tuple[str, str]: safe_language_id = _sanitize_language_id(language_id) recent_prefix, recent_suffix = _prepare_context(prefix, suffix) + recent_prefix = _normalize_newlines(recent_prefix) + recent_suffix = _normalize_newlines(recent_suffix) + + cursor_in_fenced_code_block = _cursor_in_fenced_code_block(recent_prefix) + prefix_ends_with_newline = recent_prefix.endswith("\n") + suffix_starts_with_newline = recent_suffix.startswith("\n") + tz_pref = preferences.timezone if preferences else "auto" current_time = _get_current_datetime(tz_pref) location_info = f"\nUser location: {location}" if location else "" - + pref_info = [] if preferences: - if preferences.language and preferences.language != 'auto': + if preferences.language and preferences.language != "auto": pref_info.append(f"Preferred language: {preferences.language}") - if preferences.currency and preferences.currency != 'auto': + if preferences.currency and preferences.currency != "auto": pref_info.append(f"Preferred currency: {preferences.currency}") - + preferences_instruction = "\n".join(pref_info) if preferences_instruction: preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}" - prompt = f"""Current time: {current_time}{location_info}{preferences_instruction} + user_prompt = f"""Current time: {current_time}{location_info}{preferences_instruction} +Reasoning hint: {thinking_level} +Editor language id: {safe_language_id} -You are an inline completion engine for a {safe_language_id} editor with ghost-text suggestions. +Completion state flags: +- CURSOR_IN_FENCED_CODE_BLOCK: {"true" if cursor_in_fenced_code_block else "false"} +- PREFIX_ENDS_WITH_NEWLINE: {"true" if prefix_ends_with_newline else "false"} +- SUFFIX_STARTS_WITH_NEWLINE: {"true" if suffix_starts_with_newline else "false"} -Your job: -- Return ONLY the text that should be inserted at the cursor between PREFIX and SUFFIX. -- Prefer a meaningful, non-empty insertion with moderate length. -- Avoid overly short outputs with little information value. +Task: +- Produce the best insertion text at the cursor between PREFIX and SUFFIX. +- Keep insertion meaningful and non-empty. +- Keep insertion concise unless structure requires more content. -Important context: -- PREFIX may contain OCR metadata inline after images, e.g. ![alt](url) . -- The is hidden context describing image content. -- Never copy, rewrite, or emit OCR tags in output. -- Never output . - -Hard rules: -1. Seamless join: - PREFIX + OUTPUT + SUFFIX must read naturally as one continuous document. -2. No suffix repetition: - Do NOT repeat text that already appears at the start of SUFFIX. -3. Balanced length: - Prefer concise but meaningful continuation, not ultra-short fragments. - Default target is 10-500 characters and 1-20 lines for plain prose. - You may be longer when structure requires it (lists, tables, code blocks, math blocks). -4. Avoid trivial output: - Do not output only punctuation or filler such as ".", ",", ";", ":". - Do not output just one token unless it is structurally necessary. -5. Preserve local style: - Match nearby language, tone, punctuation, spacing, and indentation. -6. Markdown awareness: - Continue active list/checkbox/ordered-list patterns when applicable. - Preserve indentation in nested list/code contexts. - You may output full markdown structures when context needs them: headings, lists, tables, fenced code blocks, blockquotes, and LaTeX ($...$ / $$...$$). - Close obvious unclosed inline markdown markers only when needed to bridge. -7. Strict output format: - Output insertion text only. - No explanations, labels, or wrapper quotes around the whole output. - Markdown syntax is allowed when it is the intended insertion (including fenced code blocks and LaTeX). +Context notes: +- PREFIX may include OCR metadata after image markdown, e.g. ![alt](url) . +- OCR metadata is hidden context and must never be copied into output. +- Preserve local style and formatting. Decision policy: -- If PREFIX already connects naturally to SUFFIX, add a brief but useful continuation when possible. -- If uncertain, prefer a complete short phrase or sentence with clear meaning. +- Prioritize seamless join: PREFIX + OUTPUT + SUFFIX must read naturally. +- Do not repeat SUFFIX-leading text. +- If uncertain, prefer a complete short phrase/sentence with clear meaning. -Examples: -The quick brown fox -jumps over the lazy dog. -Output: "moved quietly and then " - -## TODO\\n- [ ] Buy milk\\n- [ ] - -Output: "Write release notes and share draft with team" +Comprehensive examples: +{INLINE_EXAMPLES} Now produce the insertion. @@ -139,4 +301,27 @@ Now produce the insertion. Output:""" - return prompt.strip() + system_prompt = build_inline_system_prompt(safe_language_id) + return system_prompt.strip(), user_prompt.strip() + + +def build_prompt( + prefix: str, + suffix: str, + language_id: str = "markdown", + location: str = "", + thinking_level: str = "low", + preferences: object = None, +) -> str: + """ + Backward-compatible helper. Returns only the user prompt body. + """ + _, user_prompt = build_completion_prompts( + prefix=prefix, + suffix=suffix, + language_id=language_id, + location=location, + thinking_level=thinking_level, + preferences=preferences, + ) + return user_prompt diff --git a/backend/tests/test_llm.py b/backend/tests/test_llm.py new file mode 100644 index 0000000..59c713d --- /dev/null +++ b/backend/tests/test_llm.py @@ -0,0 +1,65 @@ +import asyncio +import importlib +import sys +from pathlib import Path + +import pytest + + +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +try: + llm = importlib.import_module("llm") +except ModuleNotFoundError: + pytest.skip("llm module dependencies are not available", allow_module_level=True) + + +def test_call_ollama_messages_roles_with_system(monkeypatch): + captured = {} + + async def fake_chat(**kwargs): + captured["messages"] = kwargs["messages"] + return {"message": {"content": "ok", "thinking": ""}} + + monkeypatch.setattr(llm.client, "chat", fake_chat) + + result = asyncio.run( + llm.call_ollama( + "user prompt body", + system_prompt="system prompt body", + tag="test", + temperature=0.1, + ) + ) + + assert result["content"] == "ok" + assert captured["messages"][0]["role"] == "system" + assert captured["messages"][0]["content"] == "system prompt body" + assert captured["messages"][1]["role"] == "user" + assert captured["messages"][1]["content"] == "user prompt body" + + +def test_call_ollama_messages_roles_without_system(monkeypatch): + captured = {} + + async def fake_chat(**kwargs): + captured["messages"] = kwargs["messages"] + return {"message": {"content": "ok", "thinking": ""}} + + monkeypatch.setattr(llm.client, "chat", fake_chat) + + result = asyncio.run( + llm.call_ollama( + "user prompt only", + system_prompt="", + tag="test-no-system", + temperature=0.1, + ) + ) + + assert result["content"] == "ok" + assert len(captured["messages"]) == 1 + assert captured["messages"][0]["role"] == "user" + assert captured["messages"][0]["content"] == "user prompt only" diff --git a/backend/tests/test_prompt.py b/backend/tests/test_prompt.py new file mode 100644 index 0000000..8a2daa7 --- /dev/null +++ b/backend/tests/test_prompt.py @@ -0,0 +1,56 @@ +import sys +from pathlib import Path + + +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +import prompt # noqa: E402 + + +def test_prompt_builds_system_and_user(): + system_prompt, user_prompt = prompt.build_completion_prompts( + prefix="The result is ", + suffix="for this dataset.", + language_id="markdown", + ) + + assert "Hard constraints you must follow" in system_prompt + assert "strict KaTeX-compatible math" in system_prompt + assert "$...$" in system_prompt + assert "$$...$$" in system_prompt + assert "```{language}" in system_prompt + assert "CURSOR_IN_FENCED_CODE_BLOCK" in user_prompt + assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt + assert "SUFFIX_STARTS_WITH_NEWLINE" in user_prompt + + +def test_cursor_in_fence_detection(): + assert prompt._cursor_in_fenced_code_block("") is False + assert prompt._cursor_in_fenced_code_block("```python\nprint('x')\n") is True + assert prompt._cursor_in_fenced_code_block("```python\nprint('x')\n```\n") is False + assert prompt._cursor_in_fenced_code_block("text ```not-a-fence``` tail") is False + + +def test_newline_flags(): + _, user_prompt_a = prompt.build_completion_prompts( + prefix="Hello", + suffix="World", + ) + assert "CURSOR_IN_FENCED_CODE_BLOCK: false" in user_prompt_a + assert "PREFIX_ENDS_WITH_NEWLINE: false" in user_prompt_a + assert "SUFFIX_STARTS_WITH_NEWLINE: false" in user_prompt_a + + _, user_prompt_b = prompt.build_completion_prompts( + prefix="Hello\n", + suffix="\nWorld", + ) + assert "PREFIX_ENDS_WITH_NEWLINE: true" in user_prompt_b + assert "SUFFIX_STARTS_WITH_NEWLINE: true" in user_prompt_b + + +def test_examples_coverage(): + _, user_prompt = prompt.build_completion_prompts(prefix="", suffix="") + for ex in range(1, 13): + assert f"[EX{ex:02d}]" in user_prompt diff --git a/src/components/MilkdownEditor.vue b/src/components/MilkdownEditor.vue index 447e5ae..16f9aa7 100644 --- a/src/components/MilkdownEditor.vue +++ b/src/components/MilkdownEditor.vue @@ -2,6 +2,35 @@
+
+ + +
+