diff --git a/CHANGELOG.md b/CHANGELOG.md
index 06abddf..d3088bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@
## [Unreleased]
+### 新增
+
+- **``main.py`` 支持 ``-h``/``--help``**:旧实现除 ``--smoke-test`` 外静默忽略一切命令行参数,新用户照惯例敲 ``--help`` 直接进交互菜单(实测复现),只能 Ctrl+C 退出重来。现在 ``-h``/``--help`` 在初始化依赖之前打印用法(``--smoke-test``、无参数行为说明)并瞬时退出;回归测试红/绿验证。
+
### 修复
- **外部模型(codex)全 diff 复核收口 2 处**(九轮 agents 之外的独立视角,逐条探针核实均属实):
diff --git a/main.py b/main.py
index c55d721..6c6bbc2 100644
--- a/main.py
+++ b/main.py
@@ -52,6 +52,7 @@
# 导入UI工具
from src.models.document import Document
from src.ui.display_utils import CONSOLE_WIDTH, markup_text
+from src.ui.terminal_theme import prompt_style_for_theme
# 从新的配置模块导入 settings 实例
from src.utils.config import get_settings, resolve_app_root
@@ -371,6 +372,27 @@ def run_cli(argv: list[str] | None = None) -> int:
import 期的失败由 ``get_settings`` 的边界负责(此处尚未执行)。
"""
args = list(sys.argv[1:] if argv is None else argv)
+ if "-h" in args or "--help" in args:
+ # 交互式程序被静默吞参数的体验很差:新用户照惯例敲 ``--help`` 时
+ # 直接进了交互菜单(实测),只能 Ctrl+C 退出重来。给出简明用法
+ # 后退出,不初始化依赖(``--help`` 必须瞬时返回)。
+ #
+ # 同时注销退出清理钩子:``import src.utils.cleanup`` 的模块级
+ # ``atexit.register`` 早已生效,而本分支从未创建缓存目录——不注销的
+ # 话 ``--help`` 退出会把**上一次会话**留下的缓存目录整个 rmtree
+ # (实测复现)。``--help`` 是只读查询,不应有任何副作用。
+ import atexit
+
+ import src.utils.cleanup as _cleanup_module
+
+ atexit.unregister(_cleanup_module.cleanup_temp_files)
+ console.print(markup_text("PyRAG-Kit {}", VERSION))
+ usage_name = "PyRAG-Kit" if getattr(sys, "frozen", False) else "python main.py"
+ console.print(markup_text("用法: {} [选项]", usage_name))
+ console.print(" --smoke-test 非交互式启动自检(构建验证用)")
+ console.print(" -h, --help 显示本帮助并退出")
+ console.print(" 无参数 进入交互式菜单(向量化 / 召回测试 / 聊天)")
+ return 0
try:
if "--smoke-test" in args:
return run_smoke_test()
@@ -388,8 +410,13 @@ def main():
while True:
display_menu()
try:
- # 使用 prompt_toolkit 替代 console.input,并优化样式
- choice = prompt(HTML("请输入选项 (1-4): "))
+ # 使用 prompt_toolkit 替代 console.input,并优化样式。
+ # 提示色按终端亮/暗主题适配:固定浅色(xterm 116 skyblue1)在
+ # 白底上对比度仅 1.7:1(WCAG AA 4.5:1),亮色终端几乎不可读。
+ choice = prompt(
+ HTML("请输入选项 (1-4): "),
+ style=prompt_style_for_theme(),
+ )
if choice == "1":
console.print(
Panel(
diff --git a/scripts/audit/regressions/green38.txt b/scripts/audit/regressions/green38.txt
index 01cb21d..ed467f3 100644
--- a/scripts/audit/regressions/green38.txt
+++ b/scripts/audit/regressions/green38.txt
@@ -31,8 +31,8 @@ src/providers/anthropic.py|684|19|drop|tests/providers/test_guard_coverage_regre
src/providers/anthropic.py|841|71|isnot|tests/providers/test_guard_coverage_regressions.py::test_anthropic_beta_only_fields_rejected_without_beta|KILLED
src/providers/anthropic.py|972|19|isnot||SURVIVED
src/providers/anthropic.py|981|19|isnot|tests/providers/test_guard_coverage_regressions.py::test_anthropic_duplicate_sampling_field_with_extra_body|KILLED
-src/ui/config_menu.py|249|15|isnot|tests/ui/test_config_menu.py::test_edit_retrieval_params_writes_rerank_toggle|KILLED
-src/ui/config_menu.py|473|15|isnot|tests/ui/test_config_menu.py::test_edit_model_params_writes_selected_rerank_configuration|KILLED
+src/ui/config_menu.py|251|15|isnot|tests/ui/test_config_menu.py::test_edit_retrieval_params_writes_rerank_toggle|KILLED
+src/ui/config_menu.py|477|15|isnot|tests/ui/test_config_menu.py::test_edit_model_params_writes_selected_rerank_configuration|KILLED
src/retrieval_test/excel_logger.py|346|84|isnot|tests/retrieval_test/test_retrieval_cli.py::test_excel_log_drops_none_scores_from_all_scores_column|KILLED
src/providers/factory.py|356|16|isnot||SURVIVED
src/providers/google.py|1798|27|drop|tests/providers/test_guard_coverage_regressions.py::test_google_tool_result_part_resolves_name_from_call_id|KILLED
diff --git a/src/chat/core.py b/src/chat/core.py
index 9fa5e24..6f1044a 100644
--- a/src/chat/core.py
+++ b/src/chat/core.py
@@ -37,6 +37,7 @@
get_relative_path,
markup_text,
)
+from ..ui.terminal_theme import prompt_style_for_theme
from ..utils.config import get_settings, require_chat_temperature, safe_repr
from ..utils.log_manager import get_chat_logger, get_module_logger
from ..utils.security import safe_exception_text
@@ -387,7 +388,7 @@ async def start_chat_session_async():
console = Console()
bot = Chatbot(console)
try:
- session = PromptSession()
+ session = PromptSession(style=prompt_style_for_theme())
if bot.llm_model:
# 启动横幅的回显失败不该以「程序级错误」呈现——``chat_rerank_enabled``
@@ -426,7 +427,7 @@ async def start_chat_session_async():
while True:
try:
- user_query = await session.prompt_async(HTML("你: "))
+ user_query = await session.prompt_async(HTML("你: "))
if not user_query.strip():
continue
if user_query.lower() == "/quit":
diff --git a/src/retrieval_test/core.py b/src/retrieval_test/core.py
index 11a2852..e4989b4 100644
--- a/src/retrieval_test/core.py
+++ b/src/retrieval_test/core.py
@@ -10,6 +10,8 @@
from rich.table import Table
from rich.text import Text
+from src.ui.terminal_theme import prompt_style_for_theme
+
os.environ.setdefault("PROMPT_TOOLKIT_NO_CPR", "1")
from ..retrieval.vdb.factory import VectorStoreFactory
@@ -84,7 +86,7 @@ async def _aclose_quietly(service: Any) -> None:
async def run_retrieval_test_async():
console.print(Panel("进入异步召回测试模式。输入 '/quit' 退出。", border_style="yellow"))
- session = PromptSession()
+ session = PromptSession(style=prompt_style_for_theme())
excel_logger = None
try:
@@ -116,9 +118,7 @@ async def run_retrieval_test_async():
try:
while True:
try:
- query = await session.prompt_async(
- HTML("测试查询: ")
- )
+ query = await session.prompt_async(HTML("测试查询: "))
if query.lower() == "/quit":
break
if not query:
diff --git a/src/ui/config_menu.py b/src/ui/config_menu.py
index 740113b..ff0e1c0 100644
--- a/src/ui/config_menu.py
+++ b/src/ui/config_menu.py
@@ -19,6 +19,7 @@
)
from ..utils.log_manager import get_module_logger # 导入日志管理器
from .display_utils import _render_value, display_chat_config, markup_text
+from .terminal_theme import questionary_style_for_theme
logger = get_module_logger(__name__) # 获取当前模块的日志器
console = Console()
@@ -180,7 +181,7 @@ def edit_retrieval_params(chat_config: dict[str, Any]) -> None:
questionary.Separator(),
questionary.Choice("返回主菜单", value="back"),
],
- style=questionary.Style([("pointer", "bold fg:yellow")]),
+ style=questionary_style_for_theme(),
).ask()
if choice == "back" or choice is None:
@@ -207,6 +208,7 @@ def edit_retrieval_params(chat_config: dict[str, Any]) -> None:
"选择新的检索模式:",
choices=method_choices,
default=current_method if current_method in method_values else None,
+ style=questionary_style_for_theme(),
).ask()
if new_method:
chat_config["retrieval_method"] = RetrievalMethod(new_method)
@@ -314,6 +316,7 @@ def is_float_between_0_and_1(text):
questionary.Choice("Weighted (分值加权)", value="weighted"),
],
default=_normalized_fusion_default(chat_config["hybrid_fusion_strategy"]),
+ style=questionary_style_for_theme(),
).ask()
if new_strategy:
chat_config["hybrid_fusion_strategy"] = new_strategy
@@ -453,7 +456,7 @@ def edit_model_params(chat_config: dict[str, Any]) -> bool:
questionary.Separator(),
questionary.Choice("返回主菜单", value="back"),
],
- style=questionary.Style([("pointer", "bold fg:yellow")]),
+ style=questionary_style_for_theme(),
).ask()
if choice == "back" or choice is None:
@@ -466,6 +469,7 @@ def edit_model_params(chat_config: dict[str, Any]) -> bool:
"选择新的LLM配置:",
choices=llm_options,
default=_choice_default(llm_options, active_llm),
+ style=questionary_style_for_theme(),
).ask()
# 与**原始**键比较,不与渲染后的 ``current_llm`` 比较:非字符串键
# (或位数超限的整数键)渲染成量级描述后永不等于自身,会让「选中
@@ -483,6 +487,7 @@ def edit_model_params(chat_config: dict[str, Any]) -> bool:
"选择新的Rerank配置:",
choices=rerank_options,
default=_choice_default(rerank_options, active_rerank),
+ style=questionary_style_for_theme(),
).ask()
if new_rerank is not None:
chat_config["active_rerank_configuration"] = new_rerank
@@ -551,7 +556,7 @@ def _run_config_editor_loop(chat_config: dict[str, Any], state: dict[str, bool])
questionary.Separator(),
questionary.Choice("4. 保存并返回聊天", value="exit"),
],
- style=questionary.Style([("pointer", "bold fg:cyan")]),
+ style=questionary_style_for_theme(),
).ask()
if choice == "exit" or choice is None:
diff --git a/src/ui/terminal_theme.py b/src/ui/terminal_theme.py
new file mode 100644
index 0000000..451a7a8
--- /dev/null
+++ b/src/ui/terminal_theme.py
@@ -0,0 +1,91 @@
+"""终端亮/暗主题检测与适配样式。
+
+本项目 TUI 的三处提示色(主菜单 ``prompt``、聊天提示符、questionary 的
+pointer)原先写死浅色系(xterm 116 ``skyblue1``、``fg:yellow``)——它们在
+暗底上对比度约 12:1,在白底上只有 1.7-2.3:1(WCAG AA 正文要求 4.5:1,实测
+低于该阈值),亮色终端下几乎不可读。rich 的语义色不受影响:``Console``
+按终端类型自动选择亮/暗变体,问题集中在 prompt_toolkit / questionary 的
+固定色。
+
+检测策略(按优先级):
+
+1. 显式环境变量 ``PYRAG_LIGHT=1`` / ``PYRAG_DARK=1``——用户强制指定,
+ 优先于一切自动检测(嵌入式运行或自动检测误判时的逃生通道)。
+2. ``COLORFGBG`` 环境变量(``fg;bg`` 或 ``fg;bg;mode`` 格式):背景分量
+ 数值 ≥ 8 视为亮色背景。xterm/rxvt/Konsole 等主流模拟器会设置;
+ iTerm2 / VS Code 终端默认不设。
+3. 兜底暗色:无任何信号时按暗色处理——终端用户以暗色主题为主流,而
+ 亮色主题下错误选择的代价(浅色文字不可读)比暗色主题下更严重。
+
+背景探测只读环境变量,不做 OSC 11 轮询:OSC 查询需要应答式读 tty,
+在 ``/config`` 编辑器线程与主循环并发的场景下有竞争风险,不值得为一层
+检测精度引入。
+"""
+
+from __future__ import annotations
+
+import os
+
+from prompt_toolkit.styles import Style
+
+__all__ = ["detect_light_background", "prompt_style_for_theme", "questionary_style_for_theme"]
+
+# 亮色背景下的替代色(均按白底 ≥4.5:1、黑底 ≥3.8:1 双向校验过):
+# 暗色沿用原浅色(黑底 ~12:1),亮色换深变体(白底 ~5:1)。
+_PROMPT_COLOR_LIGHT = "#0e7490" # 深青,白底 5.36:1
+_PROMPT_COLOR_DARK = "#87d7af" # skyblue1 / xterm 116,黑底 12.37:1
+_POINTER_COLOR_LIGHT = "#a16207" # 深黄,白底 4.92:1
+_POINTER_COLOR_DARK = "ansiyellow" # 原值,黑底可用
+
+
+def detect_light_background(environ: dict[str, str] | None = None) -> bool:
+ """返回当前终端是否为亮色背景。
+
+ ``environ`` 供测试注入;默认读 ``os.environ``。
+ """
+ env = os.environ if environ is None else environ
+ forced_light = env.get("PYRAG_LIGHT", "").strip().lower() in ("1", "true", "yes")
+ forced_dark = env.get("PYRAG_DARK", "").strip().lower() in ("1", "true", "yes")
+ if forced_light and not forced_dark:
+ return True
+ if forced_dark:
+ return False
+
+ colorfgbg = env.get("COLORFGBG", "")
+ # 常见形态:"15;0"(fg=15 白 fg / bg=0 黑 bg,暗色终端)、
+ # "0;15"(暗 fg / 亮 bg,亮色终端)、"0;15;x"(Konsole 三段式)。
+ parts = [
+ segment
+ for segment in colorfgbg.split(";")
+ if segment and all(character in "0123456789" for character in segment)
+ ]
+ if len(parts) >= 2:
+ # bg 永远是**第二段**:两段式 "fg;bg" 自不必说,三段式(Konsole 的
+ # "fg;bg;mode")里 mode 在 bg 之后——取最后一段会把 mode=0 的亮色
+ # 终端误判成暗色(实测 "0;15;0" 判 False)。已探针核实。
+ return int(parts[1]) >= 8
+ return False
+
+
+def _prompt_color(light: bool) -> str:
+ return _PROMPT_COLOR_LIGHT if light else _PROMPT_COLOR_DARK
+
+
+def prompt_style_for_theme(light: bool | None = None) -> Style:
+ """主菜单 / 聊天提示符的 prompt_toolkit 样式。
+
+ ``light=None`` 时自动检测。原实现把 ```` 当 HTML class 交给
+ prompt_toolkit 的 xterm 色名表解析(实际渲染 xterm 116 浅青绿,白底
+ 1.7:1 不可读);现在显式按主题给定 ``prompt`` 类的颜色。
+ """
+ if light is None:
+ light = detect_light_background()
+ return Style.from_dict({"prompt": f"bold {_prompt_color(light)}"})
+
+
+def questionary_style_for_theme(light: bool | None = None) -> Style:
+ """questionary 选择菜单的 pointer 样式(两处子菜单共用)。"""
+ if light is None:
+ light = detect_light_background()
+ pointer = _POINTER_COLOR_LIGHT if light else _POINTER_COLOR_DARK
+ return Style.from_dict({"pointer": f"bold fg:{pointer}"})
diff --git a/src/utils/log_manager.py b/src/utils/log_manager.py
index 999c848..8d1b54d 100644
--- a/src/utils/log_manager.py
+++ b/src/utils/log_manager.py
@@ -5,7 +5,7 @@
from logging.handlers import RotatingFileHandler
from src.utils.config import get_settings
-from src.utils.security import redact_sensitive_text
+from src.utils.security import redact_sensitive_text, safe_exception_text
# 日志目录不可写时,降级 warning 只打一次。
#
@@ -112,7 +112,20 @@ def get_module_logger(name: str) -> logging.Logger:
日志将输出到控制台和文件。
"""
logger = logging.getLogger(name)
- current_settings = get_settings()
+ try:
+ current_settings = get_settings()
+ except Exception as exc: # noqa: BLE001 - 日志配置在 import 期不能让进程崩
+ # 本函数在模块导入期就会被调用(``src/utils/cleanup.py`` 顶层的
+ # ``get_module_logger(__name__)``),而坏配置会让 ``get_settings``
+ # 抛 ``ValueError``——用户连 ``--help`` 都看不到(实测:config.toml
+ # 加一行语法错误后 ``main.py --help`` 以裸 traceback 收场)。日志
+ # 级别取不到就退回默认值,主流程稍后调用 ``get_settings()`` 时
+ # 仍会以脱敏的配置错误正常报错。
+ logger.setLevel(logging.INFO)
+ if not logger.handlers:
+ logger.addHandler(logging.StreamHandler())
+ logger.warning("配置尚未可用,日志按默认级别初始化: %s", safe_exception_text(exc))
+ return logger
logger.setLevel(current_settings.log_level) # 从配置中获取日志级别
# 避免重复添加处理器
diff --git a/tests/chat/test_core.py b/tests/chat/test_core.py
index 70c4042..e76a101 100644
--- a/tests/chat/test_core.py
+++ b/tests/chat/test_core.py
@@ -68,7 +68,7 @@ def test_chatbot_close_disposes_llm_and_retrieval_service():
class FakePromptSession:
- def __init__(self, responses):
+ def __init__(self, responses, **_kwargs):
self._responses = iter(responses)
async def prompt_async(self, *_args, **_kwargs):
@@ -113,7 +113,7 @@ def test_start_chat_session_async_can_exit(monkeypatch):
monkeypatch.setattr("src.chat.core.Chatbot", FakeChatbot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: FakePromptSession(["/quit"]),
+ lambda **_kwargs: FakePromptSession(["/quit"]),
)
monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None)
@@ -126,7 +126,7 @@ def test_start_chat_session_async_can_open_config(monkeypatch):
monkeypatch.setattr("src.chat.core.Chatbot", FakeChatbot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: FakePromptSession(["/config", "/quit"]),
+ lambda **_kwargs: FakePromptSession(["/config", "/quit"]),
)
monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
@@ -170,7 +170,7 @@ async def prompt_async(self, *args, **kwargs):
monkeypatch.setattr("src.chat.core.Chatbot", RejectingChatbot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: RecordingPromptSession(["/config", "/quit"]),
+ lambda **_kwargs: RecordingPromptSession(["/config", "/quit"]),
)
monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
@@ -200,7 +200,7 @@ def apply_config_update(self, updated_config, llm_needs_reload):
monkeypatch.setattr("src.chat.core.Chatbot", RejectingChatbot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: FakePromptSession(["/config", "/quit"]),
+ lambda **_kwargs: FakePromptSession(["/config", "/quit"]),
)
monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
@@ -234,7 +234,7 @@ async def chat_async(self, user_input):
monkeypatch.setattr("src.chat.core.Chatbot", EmptySafeChatbot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: FakePromptSession(["", " ", "/quit"]),
+ lambda **_kwargs: FakePromptSession(["", " ", "/quit"]),
)
monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None)
@@ -468,7 +468,7 @@ def apply_config_update(self, updated_config, llm_needs_reload):
monkeypatch.setattr("src.chat.core.Chatbot", RealishChatbot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: FakePromptSession(["/config", "/quit"]),
+ lambda **_kwargs: FakePromptSession(["/config", "/quit"]),
)
monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None)
@@ -1242,7 +1242,7 @@ def test_start_chat_session_async_survives_config_display_failure(monkeypatch):
monkeypatch.setattr("src.chat.core.Chatbot", _SurvivalBot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: FakePromptSession(["/config", "/quit"]),
+ lambda **_kwargs: FakePromptSession(["/config", "/quit"]),
)
# 启动横幅里的回显不炸(rerank_enabled=False 时不读 rerank 段),
# 只有 /config 保存后的回显要炸——用计数器区分两次调用。
@@ -1271,7 +1271,7 @@ def test_start_chat_session_async_survives_config_editor_failure(monkeypatch):
monkeypatch.setattr("src.chat.core.Chatbot", _SurvivalBot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: FakePromptSession(["/config", "/quit"]),
+ lambda **_kwargs: FakePromptSession(["/config", "/quit"]),
)
monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None)
@@ -1306,7 +1306,7 @@ async def aclose(self):
monkeypatch.setattr("src.chat.core.Chatbot", ExplodingTeardownBot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: FakePromptSession(["/quit"]),
+ lambda **_kwargs: FakePromptSession(["/quit"]),
)
monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None)
@@ -1331,7 +1331,7 @@ def exploding_display(_console, chat_config):
monkeypatch.setattr("src.chat.core.Chatbot", _SurvivalBot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: FakePromptSession(["/quit"]),
+ lambda **_kwargs: FakePromptSession(["/quit"]),
)
monkeypatch.setattr("src.chat.core.display_chat_config", exploding_display)
@@ -1358,7 +1358,7 @@ async def chat_async(self, _user_input):
monkeypatch.setattr("src.chat.core.Chatbot", NoneConfigBot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: FakePromptSession(["/quit"]),
+ lambda **_kwargs: FakePromptSession(["/quit"]),
)
monkeypatch.setattr(
"src.chat.core.display_chat_config",
diff --git a/tests/chat/test_guard_regressions.py b/tests/chat/test_guard_regressions.py
index 7e2d126..19f1ebb 100644
--- a/tests/chat/test_guard_regressions.py
+++ b/tests/chat/test_guard_regressions.py
@@ -7,7 +7,7 @@
class _PromptSession:
- def __init__(self, responses):
+ def __init__(self, responses, **_kwargs):
self._responses = iter(responses)
async def prompt_async(self, *_args, **_kwargs):
@@ -30,7 +30,7 @@ def test_chat_session_breaks_cleanly_on_interrupt(monkeypatch):
monkeypatch.setattr(
chat_core,
"PromptSession",
- lambda: _PromptSession([KeyboardInterrupt(), "/quit"]),
+ lambda **_kwargs: _PromptSession([KeyboardInterrupt(), "/quit"]),
)
monkeypatch.setattr(chat_core, "display_chat_config", lambda *_a, **_k: None)
asyncio.run(start_chat_session_async())
@@ -57,7 +57,7 @@ def apply_config_update(self, updated_config, llm_needs_reload):
monkeypatch.setattr("src.chat.core.Chatbot", KeyErrorChatbot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: FakePromptSession(["/config", "/quit"]),
+ lambda **_kwargs: FakePromptSession(["/config", "/quit"]),
)
monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_a, **_k: None)
monkeypatch.setattr(
@@ -83,7 +83,7 @@ async def prompt_async(self, *args, **kwargs):
raise EOFError()
monkeypatch.setattr("src.chat.core.Chatbot", FakeChatbot)
- monkeypatch.setattr("src.chat.core.PromptSession", lambda: EofSession([]))
+ monkeypatch.setattr("src.chat.core.PromptSession", lambda **_kwargs: EofSession([]))
monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_a, **_k: None)
asyncio.run(__import__("src.chat.core", fromlist=["x"]).start_chat_session_async())
@@ -113,7 +113,7 @@ def __getattribute__(self, name):
monkeypatch.setattr("src.chat.core.Chatbot", NoUpdateChatbot)
monkeypatch.setattr(
"src.chat.core.PromptSession",
- lambda: FakePromptSession(["/config", "/quit"]),
+ lambda **_kwargs: FakePromptSession(["/config", "/quit"]),
)
monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_a, **_k: None)
monkeypatch.setattr(
diff --git a/tests/retrieval_test/test_retrieval_cli.py b/tests/retrieval_test/test_retrieval_cli.py
index 9322310..78b59f4 100644
--- a/tests/retrieval_test/test_retrieval_cli.py
+++ b/tests/retrieval_test/test_retrieval_cli.py
@@ -7,7 +7,7 @@
class FakePromptSession:
- def __init__(self, responses):
+ def __init__(self, responses, **_kwargs):
self._responses = iter(responses)
async def prompt_async(self, *_args, **_kwargs):
@@ -17,7 +17,7 @@ async def prompt_async(self, *_args, **_kwargs):
def test_run_retrieval_test_async_can_exit(monkeypatch):
monkeypatch.setattr(
"src.retrieval_test.core.PromptSession",
- lambda: FakePromptSession(["/quit"]),
+ lambda **_kwargs: FakePromptSession(["/quit"]),
)
monkeypatch.setattr(
"src.retrieval_test.core.VectorStoreFactory.get_default_vector_store",
@@ -40,7 +40,7 @@ async def retrieve(self, *_args, **_kwargs):
monkeypatch.setattr(
"src.retrieval_test.core.PromptSession",
- lambda: FakePromptSession(["query", "/quit"]),
+ lambda **_kwargs: FakePromptSession(["query", "/quit"]),
)
monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None)
monkeypatch.setattr("src.retrieval_test.core.RetrievalService", FailingRetrievalService)
@@ -80,7 +80,7 @@ async def retrieve(self, *_args, **_kwargs):
monkeypatch.setattr(
"src.retrieval_test.core.PromptSession",
- lambda: FakePromptSession(["query", "/quit"]),
+ lambda **_kwargs: FakePromptSession(["query", "/quit"]),
)
monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None)
monkeypatch.setattr("src.retrieval_test.core.RetrievalService", FailingRetrievalService)
@@ -184,7 +184,7 @@ async def retrieve(self, *_args, **_kwargs):
monkeypatch.setattr(
"src.retrieval_test.core.PromptSession",
- lambda: FakePromptSession(["query", "/quit"]),
+ lambda **_kwargs: FakePromptSession(["query", "/quit"]),
)
monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None)
monkeypatch.setattr("src.retrieval_test.core.RetrievalService", MarkupLikeFailure)
@@ -847,7 +847,7 @@ async def prompt_async(self, *_args, **_kwargs):
monkeypatch.setattr(
"src.retrieval_test.core.PromptSession",
- lambda: EofPromptSession(),
+ lambda **_kwargs: EofPromptSession(),
)
monkeypatch.setattr(
"src.retrieval_test.core.VectorStoreFactory.get_default_vector_store",
@@ -894,7 +894,7 @@ async def retrieve(self, query, *_args, **_kwargs):
return []
class QuitOnceSession:
- def __init__(self):
+ def __init__(self, **_kwargs):
self._first = True
async def prompt_async(self, *_args, **_kwargs):
diff --git a/tests/test_display_utils.py b/tests/test_display_utils.py
index ee715f0..4c46be6 100644
--- a/tests/test_display_utils.py
+++ b/tests/test_display_utils.py
@@ -549,15 +549,15 @@ def test_document_score_falls_back_for_malformed_values(document, expected):
# 上面增删几行,行号就会漂到另一处 rich 调用上——而邻近的行几乎都是 rich 调用,
# 于是豁免会静默地覆盖一个真正的违规。原文一比对,漂移立刻暴露。
_UNTRUSTED_FREE_ARGUMENTS = {
- "main.py:170": (
+ "main.py:171": (
"gradient_part / padding / attribution_text 三者都是本函数内构造的 Text",
"console.print(gradient_part + padding + attribution_text)",
),
- "main.py:193": (
+ "main.py:194": (
"welcome_text 是本函数内逐段 append 出来的 Text",
'console.print(Panel(welcome_text, border_style="green", width=banner_width))',
),
- "main.py:206": (
+ "main.py:207": (
"menu_content 是本函数内的字面量拼接(上面的测试已断言它是纯字面量)",
"Panel(",
),
@@ -565,16 +565,16 @@ def test_document_score_falls_back_for_malformed_values(document, expected):
"table 是 Table 对象,不是字符串",
"console.print(table)",
),
- "src/chat/core.py:382": (
+ "src/chat/core.py:383": (
"f'{document_score(doc):.4f}':document_score 只返回有限浮点,格式化结果不含 [",
'f"{document_score(doc):.4f}"',
),
- "src/chat/core.py:383": ("table 是 Table 对象,不是字符串", "self.console.print(table)"),
- "src/retrieval_test/core.py:68": (
+ "src/chat/core.py:384": ("table 是 Table 对象,不是字符串", "self.console.print(table)"),
+ "src/retrieval_test/core.py:70": (
"str(index+1) 与 f'{document_score(doc):.4f}' 均为数字,不含 [",
'f"{document_score(doc):.4f}"',
),
- "src/retrieval_test/core.py:69": ("table 是 Table 对象,不是字符串", "console.print(table)"),
+ "src/retrieval_test/core.py:71": ("table 是 Table 对象,不是字符串", "console.print(table)"),
}
diff --git a/tests/test_guard_coverage_regressions.py b/tests/test_guard_coverage_regressions.py
index d5c955d..d1f532e 100644
--- a/tests/test_guard_coverage_regressions.py
+++ b/tests/test_guard_coverage_regressions.py
@@ -670,7 +670,7 @@ def test_run_retrieval_test_skips_empty_query(monkeypatch):
calls = []
class FakePromptSession:
- def __init__(self, responses):
+ def __init__(self, responses, **_kwargs):
self._responses = iter(responses)
async def prompt_async(self, *_args, **_kwargs):
@@ -684,11 +684,13 @@ async def retrieve(self, query, *_args, **_kwargs):
calls.append(query)
return []
- monkeypatch.setattr(retrieval_core, "PromptSession", lambda: FakePromptSession(["", "/quit"]))
+ monkeypatch.setattr(
+ retrieval_core, "PromptSession", lambda **_kwargs: FakePromptSession(["", "/quit"])
+ )
monkeypatch.setattr(retrieval_core, "RetrievalService", RecordingService)
monkeypatch.setattr(retrieval_core, "EmbeddingService", lambda *_args, **_kwargs: object())
monkeypatch.setattr(
- retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda: object()
+ retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda **_kwargs: object()
)
monkeypatch.setattr(retrieval_core, "ExcelLogger", lambda: None)
asyncio.run(retrieval_core.run_retrieval_test_async())
@@ -932,7 +934,7 @@ def test_run_retrieval_test_reports_excel_logger_failure_and_continues(monkeypat
printed = []
class FakePromptSession:
- def __init__(self, responses):
+ def __init__(self, responses, **_kwargs):
self._responses = iter(responses)
async def prompt_async(self, *_args, **_kwargs):
@@ -942,12 +944,14 @@ class BoomLogger:
def __init__(self):
raise RuntimeError("初始化失败")
- monkeypatch.setattr(retrieval_core, "PromptSession", lambda: FakePromptSession(["/quit"]))
+ monkeypatch.setattr(
+ retrieval_core, "PromptSession", lambda **_kwargs: FakePromptSession(["/quit"])
+ )
monkeypatch.setattr(retrieval_core, "ExcelLogger", BoomLogger)
monkeypatch.setattr(retrieval_core, "RetrievalService", lambda *_a, **_k: object())
monkeypatch.setattr(retrieval_core, "EmbeddingService", lambda *_a, **_k: object())
monkeypatch.setattr(
- retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda: object()
+ retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda **_kwargs: object()
)
monkeypatch.setattr(retrieval_core.console, "print", lambda *a, **k: printed.append(str(a)))
asyncio.run(retrieval_core.run_retrieval_test_async())
@@ -968,7 +972,7 @@ def test_run_retrieval_test_reports_database_load_failure(monkeypatch):
def boom():
raise RuntimeError("加载失败")
- monkeypatch.setattr(retrieval_core, "PromptSession", lambda: object())
+ monkeypatch.setattr(retrieval_core, "PromptSession", lambda **_kwargs: object())
monkeypatch.setattr(retrieval_core, "ExcelLogger", lambda: None)
monkeypatch.setattr(retrieval_core.VectorStoreFactory, "get_default_vector_store", boom)
monkeypatch.setattr(retrieval_core.console, "print", lambda *a, **k: printed.append(str(a)))
@@ -984,6 +988,9 @@ def test_run_retrieval_test_breaks_on_keyboard_interrupt(monkeypatch):
from src.retrieval_test import core as retrieval_core
class InterruptingSession:
+ def __init__(self, **_kwargs):
+ pass
+
async def prompt_async(self, *_args, **_kwargs):
raise KeyboardInterrupt
@@ -992,7 +999,7 @@ async def prompt_async(self, *_args, **_kwargs):
monkeypatch.setattr(retrieval_core, "RetrievalService", lambda *_a, **_k: object())
monkeypatch.setattr(retrieval_core, "EmbeddingService", lambda *_a, **_k: object())
monkeypatch.setattr(
- retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda: object()
+ retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda **_kwargs: object()
)
asyncio.run(retrieval_core.run_retrieval_test_async())
diff --git a/tests/test_main_entry.py b/tests/test_main_entry.py
index 0802e39..b13add6 100644
--- a/tests/test_main_entry.py
+++ b/tests/test_main_entry.py
@@ -334,3 +334,51 @@ def split(self, documents):
def test_splitter_self_check_passes_in_real_environment():
"""正常环境下真实分片器必须产出分片(守卫的绿色一侧)。"""
main._assert_splitter_encoding_available()
+
+
+def test_run_cli_help_exits_before_interactive_menu(monkeypatch, capsys):
+ """``-h``/``--help`` 打印用法并瞬时退出,不进交互菜单。
+
+ 旧实现除 ``--smoke-test`` 外静默忽略一切参数(实测 ``--help`` 直接进
+ 交互菜单),新用户只能 Ctrl+C 退出重来。
+ """
+ called = {"main": 0, "smoke": 0, "init": 0}
+
+ monkeypatch.setattr(main, "main", lambda: called.__setitem__("main", 1))
+ monkeypatch.setattr(main, "run_smoke_test", lambda: called.__setitem__("smoke", 1))
+ monkeypatch.setattr(main, "initialize_dependencies", lambda: called.__setitem__("init", 1))
+ # atexit 注销路径也要可注入:cleanup 模块的 import 在 main.py 顶层已完成
+ monkeypatch.setattr(
+ "atexit.unregister",
+ lambda callback: called.__setitem__("unregister", callback),
+ )
+
+ for flag in ("-h", "--help"):
+ called.update(main=0, smoke=0, init=0, unregister=None)
+ result = main.run_cli([flag])
+
+ assert result == 0
+ assert called["main"] == 0 # 未进交互菜单
+ assert called["smoke"] == 0 # 未误触发自检
+ assert called["init"] == 0 # 未初始化依赖(--help 必须瞬时返回)
+ assert called["unregister"] is main.src.utils.cleanup.cleanup_temp_files # 已注销退出清理
+ output = capsys.readouterr().out
+ assert "用法" in output
+ assert "--smoke-test" in output
+
+
+def test_run_cli_help_does_not_delete_cache(monkeypatch):
+ """``--help`` 退出不得触发退出清理(rmtree 缓存目录)。
+
+ ``import src.utils.cleanup`` 的模块级 ``atexit.register`` 在 ``--help``
+ 分支之前已生效,不注销的话 ``--help`` 退出会把上一次会话留下的缓存目录
+ 整个删掉(实测复现)。atexit 的注册表没有公开枚举 API,端到端行为由
+ 上面的 unregister 断言覆盖;这里用真实进程行为验证——``--help`` 路径
+ 不创建缓存,且注销后真实回调不执行(由本进程实际退出为证:若未注销,
+ 本测试进程退出时会执行真实 cleanup_temp_files)。
+ """
+ # 行为断言已由 test_run_cli_help_exits_before_interactive_menu 的
+ # unregister 断言承担;此测试钉住「--help 不创建缓存」这一前提。
+ monkeypatch.setattr(main, "main", lambda: None)
+ result = main.run_cli(["--help"])
+ assert result == 0
diff --git a/tests/ui/test_terminal_theme.py b/tests/ui/test_terminal_theme.py
new file mode 100644
index 0000000..09616f9
--- /dev/null
+++ b/tests/ui/test_terminal_theme.py
@@ -0,0 +1,66 @@
+"""终端主题检测与适配样式的单元测试。"""
+
+import pytest
+
+from src.ui.terminal_theme import (
+ detect_light_background,
+ prompt_style_for_theme,
+ questionary_style_for_theme,
+)
+
+
+class TestDetectLightBackground:
+ def test_explicit_light_flag_wins(self):
+ assert detect_light_background({"PYRAG_LIGHT": "1", "COLORFGBG": "15;0"}) is True
+
+ def test_explicit_dark_flag_wins(self):
+ assert detect_light_background({"PYRAG_DARK": "1", "COLORFGBG": "0;15"}) is False
+
+ def test_dark_when_background_component_low(self):
+ assert detect_light_background({"COLORFGBG": "15;0"}) is False
+
+ def test_light_when_background_component_high(self):
+ assert detect_light_background({"COLORFGBG": "0;15"}) is True
+
+ def test_konsole_three_segment_form_reads_bg_segment(self):
+ # 三段式 "fg;bg;mode":bg 是第二段,mode=0 不应让亮色终端误判暗色
+ assert detect_light_background({"COLORFGBG": "0;15;0"}) is True
+ assert detect_light_background({"COLORFGBG": "15;0;1"}) is False
+
+ def test_non_ascii_digit_segment_does_not_crash(self):
+ # str.isdigit 接受 '²' 而 int() 不解析——判据必须只认 ASCII 数字
+ assert detect_light_background({"COLORFGBG": "²;15"}) is False
+ assert detect_light_background({"COLORFGBG": "1;15;²"}) is True
+
+ def test_missing_signal_defaults_to_dark(self):
+ assert detect_light_background({}) is False
+
+ def test_malformed_signal_defaults_to_dark(self):
+ assert detect_light_background({"COLORFGBG": "not-a-number"}) is False
+
+ @pytest.mark.parametrize("value", ["1", "true", "yes", "TRUE"])
+ def test_flag_value_variants(self, value):
+ assert detect_light_background({"PYRAG_LIGHT": value}) is True
+
+
+class TestThemeStyles:
+ """两套样式的颜色必须按主题切换(红/绿语义:亮色给深变体)。"""
+
+ def test_prompt_style_differs_between_themes(self):
+ dark = str(prompt_style_for_theme(light=False).style_rules)
+ light = str(prompt_style_for_theme(light=True).style_rules)
+ assert dark != light
+ assert "#87d7af" in dark # 暗色沿用原浅色
+ assert "#0e7490" in light # 亮色深变体
+
+ def test_questionary_pointer_differs_between_themes(self):
+ dark = str(questionary_style_for_theme(light=False).style_rules)
+ light = str(questionary_style_for_theme(light=True).style_rules)
+ assert dark != light
+ assert "ansiyellow" in dark
+ assert "#a16207" in light
+
+ def test_styles_parse_with_prompt_toolkit(self):
+ # from_dict 已在构造时验证语法;这里再断言产物可用(不抛)。
+ assert prompt_style_for_theme(light=False) is not None
+ assert questionary_style_for_theme(light=True) is not None