2026-01-18 19:42:58 +08:00
|
|
|
import os
|
2026-02-14 18:28:37 +08:00
|
|
|
import time
|
|
|
|
|
import logging
|
2026-02-25 19:00:17 +08:00
|
|
|
import asyncio
|
2026-02-15 15:44:09 +08:00
|
|
|
from datetime import datetime
|
2026-05-24 23:30:32 +08:00
|
|
|
from typing import AsyncIterator
|
2026-02-07 08:53:37 +08:00
|
|
|
import ollama
|
2026-02-13 22:00:26 +08:00
|
|
|
from dotenv import load_dotenv
|
|
|
|
|
|
2026-04-05 13:42:29 +08:00
|
|
|
from prompts import get_vlm_ocr_prompt
|
|
|
|
|
|
2026-02-13 22:00:26 +08:00
|
|
|
load_dotenv()
|
2026-02-07 08:53:37 +08:00
|
|
|
|
2026-02-19 10:22:27 +08:00
|
|
|
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
2026-05-24 23:30:32 +08:00
|
|
|
PRO_OLLAMA_MODEL = os.getenv('PRO_OLLAMA_MODEL', OLLAMA_MODEL)
|
2026-04-04 20:05:40 +08:00
|
|
|
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
|
2026-02-14 18:28:37 +08:00
|
|
|
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
|
2026-02-07 08:53:37 +08:00
|
|
|
|
2026-04-05 13:42:29 +08:00
|
|
|
# Timeouts in seconds (10 minutes for large model loading)
|
|
|
|
|
COMPLETION_TIMEOUT = 600
|
2026-04-05 15:10:23 +08:00
|
|
|
OCR_TIMEOUT = 600
|
2026-04-04 20:05:40 +08:00
|
|
|
|
2026-02-07 08:53:37 +08:00
|
|
|
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
2026-02-14 18:28:37 +08:00
|
|
|
logger = logging.getLogger("llm")
|
2026-01-18 19:42:58 +08:00
|
|
|
|
2026-02-14 18:28:37 +08:00
|
|
|
|
|
|
|
|
def _extract_message(response) -> tuple[str, str]:
|
2026-02-13 22:00:26 +08:00
|
|
|
content = ""
|
|
|
|
|
thinking = ""
|
2026-02-14 18:28:37 +08:00
|
|
|
|
2026-02-13 22:00:26 +08:00
|
|
|
if hasattr(response, 'message') and response.message:
|
|
|
|
|
content = response.message.content or ""
|
|
|
|
|
thinking = getattr(response.message, 'thinking', '') or ""
|
2026-05-24 23:30:32 +08:00
|
|
|
elif isinstance(response, dict) and 'message' in response:
|
2026-02-13 22:00:26 +08:00
|
|
|
msg = response.get('message', {})
|
|
|
|
|
content = msg.get('content', '') or ""
|
|
|
|
|
thinking = msg.get('thinking', '') or ""
|
2026-02-14 18:28:37 +08:00
|
|
|
|
2026-05-24 23:30:32 +08:00
|
|
|
# fallback for generate
|
|
|
|
|
if not content:
|
|
|
|
|
if hasattr(response, 'response'):
|
|
|
|
|
content = getattr(response, 'response', '') or ""
|
|
|
|
|
elif isinstance(response, dict) and 'response' in response:
|
|
|
|
|
content = response.get('response', '') or ""
|
|
|
|
|
|
2026-02-14 18:28:37 +08:00
|
|
|
return content, thinking
|
|
|
|
|
|
|
|
|
|
|
2026-05-24 23:30:32 +08:00
|
|
|
def _build_prompt(prompt: str, system_prompt: str | None = None) -> str:
|
|
|
|
|
if system_prompt and system_prompt.strip():
|
|
|
|
|
return f"{system_prompt}\n\n{prompt}"
|
|
|
|
|
return prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_model_name(model: str | None = None, *, use_pro_model: bool = False) -> str:
|
|
|
|
|
candidate = (model or '').strip()
|
|
|
|
|
if candidate:
|
|
|
|
|
return candidate
|
|
|
|
|
return PRO_OLLAMA_MODEL if use_pro_model else OLLAMA_MODEL
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_generate_kwargs(
|
|
|
|
|
prompt: str,
|
|
|
|
|
*,
|
|
|
|
|
system_prompt: str | None = None,
|
|
|
|
|
temperature: float = 0.7,
|
|
|
|
|
thinking: str | None = None,
|
|
|
|
|
model: str | None = None,
|
|
|
|
|
use_pro_model: bool = False,
|
|
|
|
|
stream: bool = False,
|
|
|
|
|
) -> dict:
|
|
|
|
|
kwargs = {
|
|
|
|
|
"model": _resolve_model_name(model, use_pro_model=use_pro_model),
|
|
|
|
|
"prompt": _build_prompt(prompt, system_prompt),
|
|
|
|
|
"stream": stream,
|
|
|
|
|
"raw": True,
|
|
|
|
|
"options": {
|
|
|
|
|
'temperature': temperature,
|
|
|
|
|
'repeat_penalty': 1.1,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if thinking:
|
|
|
|
|
kwargs["think"] = thinking
|
|
|
|
|
return kwargs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_stream_text(chunk) -> str:
|
|
|
|
|
content, _ = _extract_message(chunk)
|
|
|
|
|
if content:
|
|
|
|
|
return content
|
|
|
|
|
|
|
|
|
|
if isinstance(chunk, dict):
|
|
|
|
|
return chunk.get('response', '') or ''
|
|
|
|
|
|
|
|
|
|
if hasattr(chunk, 'response'):
|
|
|
|
|
return getattr(chunk, 'response', '') or ''
|
|
|
|
|
|
|
|
|
|
return ''
|
|
|
|
|
|
|
|
|
|
|
2026-02-23 15:17:36 +08:00
|
|
|
async def call_ollama(
|
|
|
|
|
prompt: str,
|
|
|
|
|
*,
|
2026-04-05 10:16:16 +08:00
|
|
|
system_prompt: str | None = None,
|
2026-02-23 15:17:36 +08:00
|
|
|
tag: str = "default",
|
|
|
|
|
temperature: float = 0.7,
|
2026-04-05 10:16:16 +08:00
|
|
|
thinking: str | None = None,
|
2026-05-24 23:30:32 +08:00
|
|
|
model: str | None = None,
|
|
|
|
|
use_pro_model: bool = False,
|
2026-02-23 15:17:36 +08:00
|
|
|
) -> dict:
|
2026-02-14 18:28:37 +08:00
|
|
|
"""
|
|
|
|
|
调用 Ollama API 并返回 content 和 thinking。
|
|
|
|
|
"""
|
|
|
|
|
start = time.perf_counter()
|
2026-02-15 15:44:09 +08:00
|
|
|
start_dt = datetime.now()
|
2026-05-24 23:30:32 +08:00
|
|
|
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
|
2026-02-14 18:28:37 +08:00
|
|
|
logger.info(
|
2026-02-23 15:17:36 +08:00
|
|
|
"[LLM][%s] request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
|
2026-02-14 18:28:37 +08:00
|
|
|
tag,
|
2026-05-24 23:30:32 +08:00
|
|
|
model_name,
|
2026-02-14 18:28:37 +08:00
|
|
|
OLLAMA_HOST,
|
|
|
|
|
len(prompt),
|
2026-02-23 15:17:36 +08:00
|
|
|
len(system_prompt or ""),
|
2026-02-14 18:28:37 +08:00
|
|
|
temperature,
|
2026-02-19 10:34:31 +08:00
|
|
|
thinking,
|
2026-02-14 18:28:37 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
2026-05-24 23:30:32 +08:00
|
|
|
kwargs = _build_generate_kwargs(
|
|
|
|
|
prompt,
|
|
|
|
|
system_prompt=system_prompt,
|
|
|
|
|
temperature=temperature,
|
|
|
|
|
thinking=thinking,
|
|
|
|
|
model=model,
|
|
|
|
|
use_pro_model=use_pro_model,
|
|
|
|
|
stream=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = await asyncio.wait_for(client.generate(**kwargs), timeout=COMPLETION_TIMEOUT)
|
2026-02-25 19:00:17 +08:00
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
|
|
|
end_dt = datetime.now()
|
|
|
|
|
logger.info(
|
|
|
|
|
"[LLM][%s] call_time [%s --> %s]",
|
|
|
|
|
tag,
|
|
|
|
|
start_dt.strftime("%H:%M:%S"),
|
|
|
|
|
end_dt.strftime("%H:%M:%S"),
|
|
|
|
|
)
|
|
|
|
|
logger.warning("[LLM][%s] request cancelled after %.1fms", tag, elapsed_ms)
|
|
|
|
|
raise
|
2026-02-14 18:28:37 +08:00
|
|
|
except Exception:
|
|
|
|
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
2026-02-15 15:44:09 +08:00
|
|
|
end_dt = datetime.now()
|
|
|
|
|
logger.info(
|
|
|
|
|
"[LLM][%s] call_time [%s --> %s]",
|
|
|
|
|
tag,
|
|
|
|
|
start_dt.strftime("%H:%M:%S"),
|
|
|
|
|
end_dt.strftime("%H:%M:%S"),
|
|
|
|
|
)
|
2026-02-14 18:28:37 +08:00
|
|
|
logger.exception("[LLM][%s] request failed after %.1fms", tag, elapsed_ms)
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
content, thinking = _extract_message(response)
|
|
|
|
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
2026-02-15 15:44:09 +08:00
|
|
|
end_dt = datetime.now()
|
|
|
|
|
logger.info(
|
|
|
|
|
"[LLM][%s] call_time [%s --> %s]",
|
|
|
|
|
tag,
|
|
|
|
|
start_dt.strftime("%H:%M:%S"),
|
|
|
|
|
end_dt.strftime("%H:%M:%S"),
|
|
|
|
|
)
|
2026-02-14 18:28:37 +08:00
|
|
|
logger.info(
|
|
|
|
|
"[LLM][%s] response in %.1fms response_type=%s content_chars=%d thinking_chars=%d",
|
|
|
|
|
tag,
|
|
|
|
|
elapsed_ms,
|
|
|
|
|
type(response).__name__,
|
|
|
|
|
len(content),
|
|
|
|
|
len(thinking),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not content.strip():
|
|
|
|
|
logger.warning("[LLM][%s] empty content returned by model", tag)
|
|
|
|
|
|
2026-02-19 18:55:49 +08:00
|
|
|
return {"content": content, "think": thinking}
|
2026-02-14 18:28:37 +08:00
|
|
|
|
2026-05-24 23:30:32 +08:00
|
|
|
|
|
|
|
|
async def stream_ollama(
|
|
|
|
|
prompt: str,
|
|
|
|
|
*,
|
|
|
|
|
system_prompt: str | None = None,
|
|
|
|
|
tag: str = "default-stream",
|
|
|
|
|
temperature: float = 0.7,
|
|
|
|
|
thinking: str | None = None,
|
|
|
|
|
model: str | None = None,
|
|
|
|
|
use_pro_model: bool = False,
|
|
|
|
|
) -> AsyncIterator[str]:
|
|
|
|
|
start = time.perf_counter()
|
|
|
|
|
start_dt = datetime.now()
|
|
|
|
|
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
|
|
|
|
|
yielded_chars = 0
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"[LLM][%s] stream request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
|
|
|
|
|
tag,
|
|
|
|
|
model_name,
|
|
|
|
|
OLLAMA_HOST,
|
|
|
|
|
len(prompt),
|
|
|
|
|
len(system_prompt or ""),
|
|
|
|
|
temperature,
|
|
|
|
|
thinking,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
kwargs = _build_generate_kwargs(
|
|
|
|
|
prompt,
|
|
|
|
|
system_prompt=system_prompt,
|
|
|
|
|
temperature=temperature,
|
|
|
|
|
thinking=thinking,
|
|
|
|
|
model=model,
|
|
|
|
|
use_pro_model=use_pro_model,
|
|
|
|
|
stream=True,
|
|
|
|
|
)
|
|
|
|
|
stream = await client.generate(**kwargs)
|
|
|
|
|
iterator = stream.__aiter__()
|
|
|
|
|
deadline = time.perf_counter() + COMPLETION_TIMEOUT
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
remaining = deadline - time.perf_counter()
|
|
|
|
|
if remaining <= 0:
|
|
|
|
|
raise TimeoutError("LLM stream timed out")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
chunk = await asyncio.wait_for(iterator.__anext__(), timeout=remaining)
|
|
|
|
|
except StopAsyncIteration:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
text = _extract_stream_text(chunk)
|
|
|
|
|
if not text:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
yielded_chars += len(text)
|
|
|
|
|
yield text
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
|
|
|
end_dt = datetime.now()
|
|
|
|
|
logger.info(
|
|
|
|
|
"[LLM][%s] stream_time [%s --> %s]",
|
|
|
|
|
tag,
|
|
|
|
|
start_dt.strftime("%H:%M:%S"),
|
|
|
|
|
end_dt.strftime("%H:%M:%S"),
|
|
|
|
|
)
|
|
|
|
|
logger.warning("[LLM][%s] stream cancelled after %.1fms", tag, elapsed_ms)
|
|
|
|
|
raise
|
|
|
|
|
except Exception:
|
|
|
|
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
|
|
|
end_dt = datetime.now()
|
|
|
|
|
logger.info(
|
|
|
|
|
"[LLM][%s] stream_time [%s --> %s]",
|
|
|
|
|
tag,
|
|
|
|
|
start_dt.strftime("%H:%M:%S"),
|
|
|
|
|
end_dt.strftime("%H:%M:%S"),
|
|
|
|
|
)
|
|
|
|
|
logger.exception("[LLM][%s] stream failed after %.1fms", tag, elapsed_ms)
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
|
|
|
end_dt = datetime.now()
|
|
|
|
|
logger.info(
|
|
|
|
|
"[LLM][%s] stream_time [%s --> %s]",
|
|
|
|
|
tag,
|
|
|
|
|
start_dt.strftime("%H:%M:%S"),
|
|
|
|
|
end_dt.strftime("%H:%M:%S"),
|
|
|
|
|
)
|
|
|
|
|
logger.info(
|
|
|
|
|
"[LLM][%s] stream finished in %.1fms yielded_chars=%d",
|
|
|
|
|
tag,
|
|
|
|
|
elapsed_ms,
|
|
|
|
|
yielded_chars,
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-14 18:28:37 +08:00
|
|
|
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
|
|
|
|
start = time.perf_counter()
|
2026-02-15 15:44:09 +08:00
|
|
|
start_dt = datetime.now()
|
2026-02-14 18:28:37 +08:00
|
|
|
logger.info(
|
|
|
|
|
"[VLM][ocr] request model=%s host=%s image_bytes=%d language=%s",
|
|
|
|
|
VLM_MODEL,
|
|
|
|
|
OLLAMA_HOST,
|
|
|
|
|
len(image_bytes),
|
|
|
|
|
language,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
2026-04-04 20:05:40 +08:00
|
|
|
response = await asyncio.wait_for(
|
|
|
|
|
client.chat(
|
|
|
|
|
model=VLM_MODEL,
|
|
|
|
|
messages=[{
|
|
|
|
|
'role': 'user',
|
2026-04-05 13:42:29 +08:00
|
|
|
'content': get_vlm_ocr_prompt(),
|
2026-04-04 20:05:40 +08:00
|
|
|
'images': [image_bytes]
|
|
|
|
|
}],
|
|
|
|
|
stream=False,
|
|
|
|
|
options={'temperature': 0.3}
|
|
|
|
|
),
|
|
|
|
|
timeout=OCR_TIMEOUT
|
2026-02-14 18:28:37 +08:00
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
2026-02-15 15:44:09 +08:00
|
|
|
end_dt = datetime.now()
|
|
|
|
|
logger.info(
|
|
|
|
|
"[VLM][ocr] call_time [%s --> %s]",
|
|
|
|
|
start_dt.strftime("%H:%M:%S"),
|
|
|
|
|
end_dt.strftime("%H:%M:%S"),
|
|
|
|
|
)
|
2026-02-14 18:28:37 +08:00
|
|
|
logger.exception("[VLM][ocr] request failed after %.1fms", elapsed_ms)
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
content, thinking = _extract_message(response)
|
|
|
|
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
2026-02-15 15:44:09 +08:00
|
|
|
end_dt = datetime.now()
|
|
|
|
|
logger.info(
|
|
|
|
|
"[VLM][ocr] call_time [%s --> %s]",
|
|
|
|
|
start_dt.strftime("%H:%M:%S"),
|
|
|
|
|
end_dt.strftime("%H:%M:%S"),
|
|
|
|
|
)
|
2026-02-14 18:28:37 +08:00
|
|
|
logger.info(
|
|
|
|
|
"[VLM][ocr] response in %.1fms response_type=%s content_chars=%d thinking_chars=%d",
|
|
|
|
|
elapsed_ms,
|
|
|
|
|
type(response).__name__,
|
|
|
|
|
len(content),
|
|
|
|
|
len(thinking),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not content.strip():
|
|
|
|
|
logger.warning("[VLM][ocr] empty content returned by model")
|
|
|
|
|
|
|
|
|
|
return content
|