From 3dac7c9e5608422218837071d5191c6dfdcd2472 Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Tue, 6 Jan 2026 15:07:39 +0200 Subject: [PATCH 1/2] Decouple pandas and pydantic-ai imports from tab completion for speedups. --- .coverage | Bin 53248 -> 53248 bytes coverage.xml | 1301 +++++++++-------- reproduce_complexity/test.py | 8 + src/slopometry/cli.py | 17 +- src/slopometry/core/git_tracker.py | 10 +- src/slopometry/core/hook_handler.py | 2 +- src/slopometry/solo/__init__.py | 8 - src/slopometry/solo/cli/commands.py | 31 +- src/slopometry/summoner/__init__.py | 12 - src/slopometry/summoner/cli/commands.py | 111 +- .../summoner/services/experiment_service.py | 5 +- .../summoner/services/llm_service.py | 21 +- tests/test_save_transcript.py | 20 +- 13 files changed, 842 insertions(+), 704 deletions(-) create mode 100644 reproduce_complexity/test.py diff --git a/.coverage b/.coverage index 625f1d2833037d385460b7898e35aeed5afb7d65..01280379dc2ff2f23a88cd87f8a76f6da230ebb5 100644 GIT binary patch delta 2109 zcmZ8iZERFk8ou|=IrrXkXXc*y{{HHq70`arR;JliTCg)MooNSJkOFIm5zwM7EwGps zkj?-`i_oT=7?DJ*CYqSI39Li`C57GnvAcgj-GpuV(lz{05cVTvNq21by$4Ieo@C~E zpXWX2eb0O6nVG_vsW4_bMjF#6X)m3t|6L!`SLsgc7uJndkL3@R?UtB%!hFD7pHWBMcpCS)x5EwS?GkON*2avc)ZFDH~wNvst;> z52u_)U-m(pTgv#@NGicfO&;upgqLl}*0R>5Q}nRzWP|8tN0ZI$tEF$V`6*rMaN} z5uR^@AvIu8&c-qxc_jf_#jE<#4y@j1{b{?jL}7i|1RH6$U>Oole(LLXJI25;XQ8Zy z5il!f-C12U!I>p;s|2DS>L%>#dpA|B@9*o&Z{DyCo_R`yA#2#JHrEPG5QK8HK)^MY zm#kh(nfV}{XPzX7l&_T6Nr(QO{+`}N6w4oVmwZjTte#Q_=qWX-)X_fls`ZSy5)a@D zTBWuJT_uxff%Q3gpZw_Y(JLmldvzx+??T{&e6otsW{2Sh@nO)Dh8e_%*n$;L;yF14 zAO7r)QYL`oYY-S>vInYw4{+>(Jzxbf%KBHV5F=2%&M<-)W(T{LiXqn6mBGPf zs5pmLT-dI=diwic%x&Aa^`%YgH^QR`v>Mmz9^=x4)E*Ml|O*w>1{8u?@$Si^z6 z4X7vr`z4sl1glS>BH<@S z+Ra$`d7wJ?KyQ9?F26b7`|^&BTVYgrF#^XJl|xyAaT0-J4Az~sVl-RBwjuxP{ImIO z#=hd;z~TZD1T_)_ZBd*Rh5~W`!g?DU8}bhNA$&}gdgLylUO0Q{$s6Ab7ivsJ z*@s24$0AJ)lY*cMq7AAXE18XAGpBEM2iuK^ue)b%n`UD{SSOqn1)(r_4c>oVXO-DV z*qwv00I7NaYRC(}G2R71=b8~2@_3aI!J5WaFI+GbK2i~H`fGSbjd+YH0&H}@e`Tv1 z6H@d{9u|C3NfPejf=So~hrYSjTY~Uj69{BOhu6Jjuoniu|JS*{BrgBw>dDt;ri;}s zW8iguHogDYJm2ZZ<~F`^V%@oGwvyH9fR=OZJ0|vAeB=hZ<1TFPsjUpzLHtF8U3?=f zD(uc1QPIrY2cCC$*Gx!jfBG@eGc5(Iuw|jlh7W{A%sxJFNJOmR;NumZsYiGI6u3FF z)j|mDaD`ro{7unS2qVGMHKpVD#F?p2b}4Y3;&0sSy@Rj7cP2VqXKyTwr~Yy6`t!y0 z7;<}4CbnVtyD&XHG45BjTkVC<8>Y^iq{~~z!=)#xO;GMxE88$qBWmoekv8}#zCIFd ziBp$A`{^2bm7bu#r61B!+DYT|-}Eq@qBrS9dYxwIURq5PbRNAxKd0}~U35F`p?{{o zr+K=c2C0iKqDgS_pL8~J6c5JYGPDw-WgJM1gDc9x5#itrb0`aOhzB{?0vuF72g%1F z?Bx*fU_^-M=EU#f;B#`YJ2*t_928p*M>FN%)j7yk4%os0nK^hg4k5xJsB&;C9CVq3 X3I1eY6pA=lO&l}{gGemzk5&H<7hNF7 delta 2301 zcmY*Ydr*|u75^UhevjS#zPnEr_RV97*aQ(&XeY|X^45^Pc6IVc zR50{5O=}-9`JzQtqDG>>CYwlD|6D($uh9kVgf^t5S&myaSe)kf%n5Uz=|j^tQ&{~# z-KAzJe^w4CrSf(82l7(sqBJIz;4kn`@#ENrevdYzf}<0cMEq#3$c@s^YVx&syuWkf z=6F1Ra9d-T&Qvr?u^hxD{XjOxMNyIB>UC5&c!nCAIj$U262DU)I{`do@m>-A5Ni+oiQ$V2bWB zXUZJHDi8gnW|a`lpySbEI#sFKx_d@?x<&?up>I_>y; z`hJB=XmHWXWo7iKGF@nJf>}kgYgRe?`ugKrdWN6c*wq6?*4iIvI#}Tlnr-ym+A{ij zt(~`m)hv}0$RS1}>xPXZ@sZ)qu1!701W_I4ib|_B;2&ax%*DLe0$z}L^|fijdNUm= zkMky&YMoH2(xIwKsX~DiH=_57UwEgJBC!)Aq90fTnvfL-5T$=fK33XTcaJUwMkU%Z;A5|`#Q-eTA zhF4l`$JwPQ$s3;Ku9C7U5j=}QpQ$E%CSBZAPI|u=-#XYg(AmviJOqWCaPV%?W%NkQ zfdlIh2_Jz*>W?6$r!w zmC1wRAuvV^>kSB`WAvSid9YT8Kspm`%joJ_CvTyH&2iogQ?!D923xAI${y%+djY*( z@8A`fXNe&jEPoe)YK$teIx{apZqZ%USV2QCuYU}qas)atGQ9N~7Qe~2-KP_w9woC4 z?d8)Al{)RHB5!q|kQi28rE+3n=4+1}U8&}&vhr``W96js z>f1JJv$DhrDekx(YBcrG;R4jG3X(_)Jb3g$S4ix?Y+(t<$b`%Q%r znM>@r`Pt9!IJn;mx7=y~;?7X3Bf;DKVCI^pF9?S*98JLD13rkYFwb5;yITeyz7VV_ z9yWnXL`uD0;7fel_x4=nZY+F#bo!#)*fyK`Wr9!qHDz^qfPL61^hzyU2{(0d`PIL1 z=L*Gz84zCMo&C>(;->|u#Bprec{2p=a-%w1)+CpXd36uxp1m=OXTk|iuttEI`sX5J zpL}aae~B@S@lD;_^<;3~G9j?Z7t$8qzg(u>N>E&p51yuXuEyAxc83W z(I90RrsD_vJfd$L_&zVv(y^tFWX*nS$8FEG)UZWWA;X#eWZ*Bdu7DZHI8iuv6dyY^ z`}R)RxY{$B^yRT#JfXqyB{pQ!ub;RunW30&)Fn=q&Yl&8 zOGA_Xxkn2`DECB=b{{N+zvhbvYxw}ZaxhpI)>9nmC7+UG2!m{$0oE8G3j@*2Aj`xcU1gA=Fvye{5Q%|+8R&>XP-I{c NFycj34ZL!=;D2y+T{Zv! diff --git a/coverage.xml b/coverage.xml index 20c0111..875714e 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -16,7 +16,7 @@ - + @@ -1138,7 +1138,7 @@ - + @@ -1313,38 +1313,37 @@ - - - - - - - - - - - - + + + + + + + + - - - - - - - + + + + + + + - - - - - - - + + + + + + + + + + @@ -4272,39 +4271,37 @@ - + - + - - - - + + - + - - - - + + + + - + - - + + @@ -4319,163 +4316,159 @@ - + - + - - + - - + + + - - - - - - - - - + + + + + + + + + - - + - - - - - - - + + + + + + + + - - - - + + + + - + - - - + - + + - - + + + + + + + - - - - - - - - + + + - + - + + - + - - - - - - - - - - - - - - + + + + + + + + + + + + - - + - + - + + - + + - - - - + + + + + + - - - - + + - + + - - - - - - + + + + - - @@ -4484,13 +4477,12 @@ - - - - + + + - + @@ -4499,134 +4491,153 @@ + - + - + + + + - - - + - + - + - - - - + + + + + + + - + - - - - - - - + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - + + + - - - + + + + + + + - - - + - - - - - + + + - - - + - - - - - + + + + - + - - - - - + + + + - - - + + - + + + + + + + + + + + + + + + + + + + + + @@ -4784,9 +4795,9 @@ - + - + @@ -4797,174 +4808,178 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - + + - - - - - + + + + - - - - - - - + + + + - + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - + + + + + + + + + - - + + + + + + - - - - - + + + + - - - + + + - - + - - - - + + + + + + - + - - - - + + + + + - - - + + - - - - - - - - - + + + + + + + + + + - + + - - + @@ -4972,90 +4987,86 @@ + - + + - - - + + - - + - + - + + - - - - - - + + + + + - - - + + + + - - - - + + + - + - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + - - - - @@ -5065,396 +5076,435 @@ + + + + - + - + - + + + - + + - - - - - - - + + + + - - - - - - - + + + + + + - + + - - + - + + + - + - - + + - + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - + + + + + - + - - - - - + + + - - - + + + + + - + - + - + - - - + + - + - - + - - - - - - + + + + + + - - + - - - + + + + + - + - - - - - - - - - - + + + - - + + + - - - + + + + + + + + - + - - - - + + + + + + - - - - - - + + + + - - + - - + - - - - - - - - + + + + + + - - - - - + + + + + + - + + + - - - + + + + + + - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -5483,10 +5533,10 @@ - - + + - + @@ -5500,7 +5550,7 @@ - + @@ -5519,7 +5569,7 @@ - + @@ -5534,7 +5584,7 @@ - + @@ -5546,7 +5596,7 @@ - + @@ -5563,12 +5613,12 @@ - + - + @@ -5946,61 +5996,62 @@ - + - - + + - - - - + + + - - - + + + - - - - - - + + + + + + - + - - - - - - - - - - + + + + + + + + + + - - - - - - - + + + + + + - + + + + @@ -6072,19 +6123,19 @@ - + - - - - - - - - - - + + + + + + + + + + @@ -6104,11 +6155,11 @@ - - + + - + @@ -6117,26 +6168,28 @@ - - - + + + - + - - - - + + + + - + - - - - + + + + + + @@ -6230,43 +6283,43 @@ - + - - - - + + + + - + - + - + - + - + - + - + diff --git a/reproduce_complexity/test.py b/reproduce_complexity/test.py new file mode 100644 index 0000000..8e4096f --- /dev/null +++ b/reproduce_complexity/test.py @@ -0,0 +1,8 @@ +def foo(): + pass + + +def bar(x): + if x: + return 1 + return 0 diff --git a/src/slopometry/cli.py b/src/slopometry/cli.py index 7a4ea16..0e21fac 100644 --- a/src/slopometry/cli.py +++ b/src/slopometry/cli.py @@ -97,11 +97,20 @@ def shell_completion(shell: str) -> None: console.print("_SLOPOMETRY_COMPLETE=bash_source slopometry > ~/.slopometry-complete.sh") console.print("echo 'source ~/.slopometry-complete.sh' >> ~/.bashrc") elif shell == "zsh": - console.print("[bold]Add this to your ~/.zshrc:[/bold]") + console.print("[bold]Manual Installation (Recommended for Oh My Zsh):[/bold]") + console.print("mkdir -p ~/.oh-my-zsh/completions") + console.print( + "_SLOPOMETRY_COMPLETE=zsh_source slopometry | sed '/commands\\[slopometry\\]/d' > " + "~/.oh-my-zsh/completions/_slopometry" + ) + console.print("\n[dim]Note: You may need to run 'compinit' or restart your shell.[/dim]") + + console.print("\n[bold]Configuration (Cleanup):[/bold]") + console.print("To hide internal functions like '_slopometry' from tab completion, add this to your ~/.zshrc:") + console.print("zstyle ':completion:*:*:-command-:*:*' ignored-patterns '_slopometry*'") + + console.print("\n[bold]Alternative (Direct Eval):[/bold]") console.print('eval "$(_SLOPOMETRY_COMPLETE=zsh_source slopometry)"') - console.print("\n[bold]Or install directly:[/bold]") - console.print("_SLOPOMETRY_COMPLETE=zsh_source slopometry > ~/.slopometry-complete.zsh") - console.print("echo 'source ~/.slopometry-complete.zsh' >> ~/.zshrc") elif shell == "fish": console.print("[bold]Add this to your fish config:[/bold]") console.print("_SLOPOMETRY_COMPLETE=fish_source slopometry | source") diff --git a/src/slopometry/core/git_tracker.py b/src/slopometry/core/git_tracker.py index 6554788..7b52bc3 100644 --- a/src/slopometry/core/git_tracker.py +++ b/src/slopometry/core/git_tracker.py @@ -404,10 +404,14 @@ def extract_specific_files_from_commit(self, commit_ref: str, file_paths: list[s except (subprocess.TimeoutExpired, subprocess.SubprocessError): failed_files.append(file_path) + # Don't error on files that don't exist in this commit + # (e.g., newly added files when extracting from parent commit) if not any(temp_dir.rglob("*.py")): - shutil.rmtree(temp_dir, ignore_errors=True) - if failed_files: - raise GitOperationError(f"Failed to extract any files from {commit_ref}. Failed: {failed_files}") + if failed_files and len(failed_files) == len(file_paths): + raise GitOperationError( + f"Failed to extract any files from {commit_ref}. " + f"These files may not exist in this commit. Failed: {failed_files}" + ) return None return temp_dir diff --git a/src/slopometry/core/hook_handler.py b/src/slopometry/core/hook_handler.py index abe3ecb..ac76399 100644 --- a/src/slopometry/core/hook_handler.py +++ b/src/slopometry/core/hook_handler.py @@ -623,7 +623,7 @@ def format_code_smell_feedback( if blocking_smells: lines.append("") - lines.append("**ACTION REQUIRED** - The following issues are in files you edited:") + lines.append("**ACTION REQUIRED** - The following issues are in files that are in scope for this PR:") lines.append("") for label, file_count, change, guidance, related_files in blocking_smells: change_str = f" (+{change})" if change > 0 else f" ({change})" if change < 0 else "" diff --git a/src/slopometry/solo/__init__.py b/src/slopometry/solo/__init__.py index 48bd450..63bcde0 100644 --- a/src/slopometry/solo/__init__.py +++ b/src/slopometry/solo/__init__.py @@ -1,9 +1 @@ """Solo-leveler features for basic session tracking and analysis.""" - -from slopometry.solo.services.hook_service import HookService -from slopometry.solo.services.session_service import SessionService - -__all__ = [ - "SessionService", - "HookService", -] diff --git a/src/slopometry/solo/cli/commands.py b/src/slopometry/solo/cli/commands.py index 7dc3b5a..54219df 100644 --- a/src/slopometry/solo/cli/commands.py +++ b/src/slopometry/solo/cli/commands.py @@ -6,10 +6,7 @@ import click from rich.console import Console -from slopometry.core.settings import settings -from slopometry.display.formatters import create_sessions_table, display_session_summary -from slopometry.solo.services.hook_service import HookService -from slopometry.solo.services.session_service import SessionService +# Imports moved inside functions to optimize startup time console = Console() logger = logging.getLogger(__name__) @@ -17,6 +14,8 @@ def complete_session_id(ctx: click.Context, param: click.Parameter, incomplete: str) -> list[str]: """Complete session IDs from the database.""" + from slopometry.solo.services.session_service import SessionService + try: session_service = SessionService() sessions = session_service.list_sessions() @@ -50,6 +49,7 @@ def _warn_if_not_in_path() -> None: def install(global_: bool) -> None: """Install slopometry hooks into Claude Code settings to automatically track all sessions and tool usage.""" from slopometry.core.settings import get_default_config_dir, get_default_data_dir + from slopometry.solo.services.hook_service import HookService hook_service = HookService() success, message = hook_service.install_hooks(global_) @@ -73,6 +73,8 @@ def install(global_: bool) -> None: ) def uninstall(global_: bool) -> None: """Remove slopometry hooks from Claude Code settings to completely stop automatic session tracking.""" + from slopometry.solo.services.hook_service import HookService + hook_service = HookService() success, message = hook_service.uninstall_hooks(global_) @@ -86,6 +88,9 @@ def uninstall(global_: bool) -> None: @click.option("--limit", default=None, type=int, help="Number of recent sessions to show") def list_sessions(limit: int) -> None: """List recent Claude Code sessions.""" + from slopometry.display.formatters import create_sessions_table + from slopometry.solo.services.session_service import SessionService + session_service = SessionService() sessions_data = session_service.get_sessions_for_display(limit=limit) @@ -106,6 +111,9 @@ def show(session_id: str, smell_details: bool, file_details: bool) -> None: """Show detailed statistics for a session.""" import time + from slopometry.display.formatters import display_session_summary + from slopometry.solo.services.session_service import SessionService + start_time = time.perf_counter() session_service = SessionService() @@ -137,6 +145,9 @@ def latest(smell_details: bool, file_details: bool) -> None: """Show detailed statistics for the most recent session.""" import time + from slopometry.display.formatters import display_session_summary + from slopometry.solo.services.session_service import SessionService + start_time = time.perf_counter() session_service = SessionService() @@ -226,8 +237,11 @@ def cleanup(session_id: str | None, all_sessions: bool, yes: bool) -> None: If SESSION_ID is provided, delete that specific session. If --all is provided, delete all sessions. + If --all is provided, delete all sessions. Otherwise, show usage help. """ + from slopometry.solo.services.session_service import SessionService + session_service = SessionService() if session_id and all_sessions: @@ -283,6 +297,10 @@ def cleanup(session_id: str | None, all_sessions: bool, yes: bool) -> None: @click.command() def status() -> None: """Show installation status and hook configuration.""" + from slopometry.core.settings import settings + from slopometry.solo.services.hook_service import HookService + from slopometry.solo.services.session_service import SessionService + hook_service = HookService() status_info = hook_service.get_installation_status() @@ -316,6 +334,8 @@ def status() -> None: @click.option("--enable/--disable", default=None, help="Enable or disable stop event feedback") def feedback(enable: bool | None) -> None: """Configure complexity feedback on stop events.""" + from slopometry.core.settings import settings + if enable is None: current_status = "enabled" if settings.enable_complexity_feedback else "disabled" console.print(f"[bold]Complexity feedback is currently {current_status}[/bold]") @@ -390,6 +410,7 @@ def feedback(enable: bool | None) -> None: def migrations() -> None: """Show database migration status.""" from slopometry.core.migrations import MigrationRunner + from slopometry.solo.services.session_service import SessionService session_service = SessionService() migration_runner = MigrationRunner(session_service.db.db_path) @@ -457,6 +478,8 @@ def save_transcript(session_id: str | None, output_dir: str, yes: bool) -> None: import shutil from pathlib import Path + from slopometry.solo.services.session_service import SessionService + session_service = SessionService() # If no session_id provided, use the latest session diff --git a/src/slopometry/summoner/__init__.py b/src/slopometry/summoner/__init__.py index df4618e..44ee736 100644 --- a/src/slopometry/summoner/__init__.py +++ b/src/slopometry/summoner/__init__.py @@ -1,13 +1 @@ """Summoner features for advanced experimentation and AI integration.""" - -from slopometry.summoner.services.experiment_service import ExperimentService -from slopometry.summoner.services.llm_service import LLMService -from slopometry.summoner.services.nfp_service import NFPService -from slopometry.summoner.services.user_story_service import UserStoryService - -__all__ = [ - "ExperimentService", - "UserStoryService", - "LLMService", - "NFPService", -] diff --git a/src/slopometry/summoner/cli/commands.py b/src/slopometry/summoner/cli/commands.py index a3fbbbb..66886ca 100644 --- a/src/slopometry/summoner/cli/commands.py +++ b/src/slopometry/summoner/cli/commands.py @@ -10,40 +10,16 @@ from click.shell_completion import CompletionItem from rich.console import Console -from slopometry.core.complexity_analyzer import ComplexityAnalyzer -from slopometry.core.database import EventDatabase -from slopometry.core.git_tracker import GitOperationError, GitTracker -from slopometry.core.language_guard import check_language_support -from slopometry.core.models import ComplexityDelta, LeaderboardEntry, ProjectLanguage -from slopometry.core.working_tree_extractor import WorkingTreeExtractor -from slopometry.summoner.services.baseline_service import BaselineService -from slopometry.summoner.services.current_impact_service import CurrentImpactService -from slopometry.summoner.services.impact_calculator import ImpactCalculator -from slopometry.summoner.services.qpe_calculator import QPECalculator - -logger = logging.getLogger(__name__) - -from slopometry.display.formatters import ( - create_experiment_table, - create_features_table, - create_nfp_objectives_table, - create_progress_history_table, - create_user_story_entries_table, - display_baseline_comparison, - display_current_impact_analysis, - display_leaderboard, - display_qpe_score, -) -from slopometry.summoner.services.experiment_service import ExperimentService -from slopometry.summoner.services.llm_service import LLMService -from slopometry.summoner.services.nfp_service import NFPService -from slopometry.summoner.services.user_story_service import UserStoryService +# Imports moved inside functions to optimize startup time console = Console() +logger = logging.getLogger(__name__) def complete_experiment_id(ctx: click.Context, param: click.Parameter, incomplete: str) -> list[str]: """Complete experiment IDs from the database.""" + from slopometry.summoner.services.experiment_service import ExperimentService + try: experiment_service = ExperimentService() experiments = experiment_service.list_experiments() @@ -54,6 +30,8 @@ def complete_experiment_id(ctx: click.Context, param: click.Parameter, incomplet def complete_nfp_id(ctx: click.Context, param: click.Parameter, incomplete: str) -> list[str]: """Complete NFP objective IDs from the database.""" + from slopometry.summoner.services.nfp_service import NFPService + try: nfp_service = NFPService() objectives = nfp_service.list_nfp_objectives() @@ -64,6 +42,8 @@ def complete_nfp_id(ctx: click.Context, param: click.Parameter, incomplete: str) def complete_feature_id(ctx: click.Context, param: click.Parameter, incomplete: str) -> list[str]: """Complete feature IDs from the database.""" + from slopometry.core.database import EventDatabase + try: db = EventDatabase() repo_path = Path.cwd() @@ -75,6 +55,8 @@ def complete_feature_id(ctx: click.Context, param: click.Parameter, incomplete: def complete_user_story_entry_id(ctx: click.Context, param: click.Parameter, incomplete: str) -> list[str]: """Complete user story entry IDs from the database.""" + from slopometry.core.database import EventDatabase + try: db = EventDatabase() entry_ids = db.get_user_story_entry_ids_for_completion() @@ -132,6 +114,10 @@ def summoner() -> None: ) def run_experiments(commits: int, max_workers: int, repo_path: Path | None) -> None: """Run parallel experiments across git commits to track and analyze code complexity evolution patterns.""" + from slopometry.core.language_guard import check_language_support + from slopometry.core.models import ProjectLanguage + from slopometry.summoner.services.experiment_service import ExperimentService + if repo_path is None: repo_path = Path.cwd() @@ -174,6 +160,10 @@ def run_experiments(commits: int, max_workers: int, repo_path: Path | None) -> N ) def analyze_commits(start: str | None, end: str | None, repo_path: Path | None) -> None: """Analyze complexity evolution across a chain of commits.""" + from slopometry.core.language_guard import check_language_support + from slopometry.core.models import ProjectLanguage + from slopometry.summoner.services.experiment_service import ExperimentService + if repo_path is None: repo_path = Path.cwd() @@ -207,6 +197,13 @@ def analyze_commits(start: str | None, end: str | None, repo_path: Path | None) def _show_commit_range_baseline_comparison(repo_path: Path, start: str, end: str) -> None: """Show baseline comparison for analyzed commit range.""" + from slopometry.core.complexity_analyzer import ComplexityAnalyzer + from slopometry.core.git_tracker import GitOperationError, GitTracker + from slopometry.core.models import ComplexityDelta + from slopometry.display.formatters import display_baseline_comparison + from slopometry.summoner.services.baseline_service import BaselineService + from slopometry.summoner.services.impact_calculator import ImpactCalculator + console.print("\n[yellow]Computing baseline comparison...[/yellow]") baseline_service = BaselineService() @@ -316,6 +313,13 @@ def current_impact( if repo_path is None: repo_path = Path.cwd() + from slopometry.core.language_guard import check_language_support + from slopometry.core.models import ProjectLanguage + from slopometry.core.working_tree_extractor import WorkingTreeExtractor + from slopometry.display.formatters import display_current_impact_analysis + from slopometry.summoner.services.baseline_service import BaselineService + from slopometry.summoner.services.current_impact_service import CurrentImpactService + guard = check_language_support(repo_path, ProjectLanguage.PYTHON) if warning := guard.format_warning(): console.print(f"[dim]{warning}[/dim]") @@ -385,6 +389,9 @@ def current_impact( @summoner.command("list-experiments") def list_experiments() -> None: """List all experiment runs.""" + from slopometry.display.formatters import create_experiment_table + from slopometry.summoner.services.experiment_service import ExperimentService + experiment_service = ExperimentService() experiments_data = experiment_service.list_experiments() @@ -400,6 +407,9 @@ def list_experiments() -> None: @click.argument("experiment_id", shell_complete=complete_experiment_id) def show_experiment(experiment_id: str) -> None: """Show detailed progress for an experiment.""" + from slopometry.display.formatters import create_progress_history_table + from slopometry.summoner.services.experiment_service import ExperimentService + experiment_service = ExperimentService() result = experiment_service.get_experiment_details(experiment_id) @@ -445,6 +455,9 @@ def userstorify( base_commit: str | None, head_commit: str | None, feature_id: str | None, repo_path: Path | None ) -> None: """Generate user stories from commits using configured AI agents and save permanently to user story collection.""" + from slopometry.core.database import EventDatabase + from slopometry.summoner.services.llm_service import LLMService + if repo_path is None: repo_path = Path.cwd() @@ -532,6 +545,8 @@ def userstorify( @click.option("--unrated-only", is_flag=True, help="Show only unrated entries (rating = 3)") def rate_user_stories(limit: int, filter_model: str | None, unrated_only: bool) -> None: """Rate existing user stories in the user story collection.""" + from slopometry.summoner.services.user_story_service import UserStoryService + user_story_service = UserStoryService() try: @@ -622,6 +637,9 @@ def rate_user_stories(limit: int, filter_model: str | None, unrated_only: bool) ) def list_features(limit: int, repo_path: Path | None) -> None: """List detected feature boundaries from merge commits.""" + from slopometry.display.formatters import create_features_table + from slopometry.summoner.services.llm_service import LLMService + if repo_path is None: repo_path = Path.cwd() @@ -653,6 +671,8 @@ def list_features(limit: int, repo_path: Path | None) -> None: @summoner.command("user-story-stats") def user_story_stats() -> None: """Show statistics about the collected user stories.""" + from slopometry.summoner.services.user_story_service import UserStoryService + user_story_service = UserStoryService() try: @@ -680,11 +700,18 @@ def user_story_stats() -> None: @click.option("--limit", "-l", default=10, help="Number of entries to show (default: 10)") def list_user_stories(limit: int) -> None: """Show recent user story entries.""" + from slopometry.display.formatters import create_user_story_entries_table + from slopometry.summoner.services.user_story_service import UserStoryService + user_story_service = UserStoryService() try: entries = user_story_service.get_user_story_entries(limit) + if not entries: + console.print("[yellow]No user story entries found[/yellow]") + return + if not entries: console.print("[yellow]No user story entries found[/yellow]") return @@ -703,6 +730,8 @@ def list_user_stories(limit: int) -> None: @click.option("--hf-repo", help="Hugging Face user story repository (e.g., username/repository-name)") def user_story_export(output: str | None, upload_to_hf: bool, hf_repo: str | None) -> None: """Export user stories to Parquet format.""" + from slopometry.summoner.services.user_story_service import UserStoryService + user_story_service = UserStoryService() if output is None: @@ -745,6 +774,8 @@ def user_story_export(output: str | None, upload_to_hf: bool, hf_repo: str | Non @click.argument("entry_id", shell_complete=complete_user_story_entry_id) def show_user_story(entry_id: str) -> None: """Show detailed information for a user story entry.""" + from slopometry.core.database import EventDatabase + db = EventDatabase() match len(entry_id): @@ -795,6 +826,8 @@ def show_user_story(entry_id: str) -> None: @click.option("--repo-path", "-r", type=click.Path(exists=True, path_type=Path), help="Repository path filter") def list_nfp(repo_path: Path | None) -> None: """List all NFP objectives.""" + from slopometry.summoner.services.nfp_service import NFPService + nfp_service = NFPService() try: @@ -806,6 +839,8 @@ def list_nfp(repo_path: Path | None) -> None: return objectives_data = nfp_service.prepare_objectives_data_for_display(objectives) + from slopometry.display.formatters import create_nfp_objectives_table + table = create_nfp_objectives_table(objectives_data) console.print(table) @@ -817,6 +852,8 @@ def list_nfp(repo_path: Path | None) -> None: @click.argument("nfp_id", shell_complete=complete_nfp_id) def show_nfp(nfp_id: str) -> None: """Show detailed information for an NFP objective.""" + from slopometry.summoner.services.nfp_service import NFPService + nfp_service = NFPService() try: @@ -869,6 +906,8 @@ def show_nfp(nfp_id: str) -> None: @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") def delete_nfp(nfp_id: str, yes: bool) -> None: """Delete an NFP objective and all its user stories.""" + from slopometry.summoner.services.nfp_service import NFPService + nfp_service = NFPService() if not yes: @@ -922,6 +961,9 @@ def qpe(repo_path: Path | None, output_json: bool) -> None: if repo_path is None: repo_path = Path.cwd() + from slopometry.core.language_guard import check_language_support + from slopometry.core.models import ProjectLanguage + guard = check_language_support(repo_path, ProjectLanguage.PYTHON) if warning := guard.format_warning(): if not output_json: @@ -938,6 +980,9 @@ def qpe(repo_path: Path | None, output_json: bool) -> None: console.print("[bold]Computing Quality-Per-Effort score[/bold]") console.print(f"Repository: {repo_path}") + from slopometry.core.complexity_analyzer import ComplexityAnalyzer + from slopometry.summoner.services.qpe_calculator import QPECalculator + analyzer = ComplexityAnalyzer(working_directory=repo_path) metrics = analyzer.analyze_extended_complexity() @@ -947,6 +992,8 @@ def qpe(repo_path: Path | None, output_json: bool) -> None: if output_json: print(qpe_score.model_dump_json(indent=2)) else: + from slopometry.display.formatters import display_qpe_score + display_qpe_score(qpe_score, metrics) except Exception as e: @@ -982,9 +1029,17 @@ def compare_projects(append_paths: tuple[Path, ...]) -> None: slopometry summoner compare-projects -a /path/to/project1 -a /path/to/project2 """ + from slopometry.core.database import EventDatabase + from slopometry.display.formatters import display_leaderboard + db = EventDatabase() if append_paths: + from slopometry.core.complexity_analyzer import ComplexityAnalyzer + from slopometry.core.language_guard import check_language_support + from slopometry.core.models import LeaderboardEntry, ProjectLanguage + from slopometry.summoner.services.qpe_calculator import QPECalculator + qpe_calculator = QPECalculator() for project_path in append_paths: diff --git a/src/slopometry/summoner/services/experiment_service.py b/src/slopometry/summoner/services/experiment_service.py index 60e898e..c433a4e 100644 --- a/src/slopometry/summoner/services/experiment_service.py +++ b/src/slopometry/summoner/services/experiment_service.py @@ -5,7 +5,6 @@ from slopometry.core.database import EventDatabase from slopometry.core.models import ExperimentDisplayData, ProgressDisplayData -from slopometry.summoner.services.experiment_orchestrator import ExperimentOrchestrator class ExperimentService: @@ -16,6 +15,8 @@ def __init__(self, db: EventDatabase | None = None): def run_parallel_experiments(self, repo_path: Path, commits: int, max_workers: int) -> dict: """Run parallel experiments across git commits.""" + from slopometry.summoner.services.experiment_orchestrator import ExperimentOrchestrator + orchestrator = ExperimentOrchestrator(repo_path) commit_pairs = [] @@ -28,6 +29,8 @@ def run_parallel_experiments(self, repo_path: Path, commits: int, max_workers: i def analyze_commit_chain(self, repo_path: Path, base_commit: str, head_commit: str) -> None: """Analyze complexity evolution across a chain of commits.""" + from slopometry.summoner.services.experiment_orchestrator import ExperimentOrchestrator + orchestrator = ExperimentOrchestrator(repo_path) orchestrator.analyze_commit_chain(base_commit, head_commit) diff --git a/src/slopometry/summoner/services/llm_service.py b/src/slopometry/summoner/services/llm_service.py index 1c2a0f2..e7584ba 100644 --- a/src/slopometry/summoner/services/llm_service.py +++ b/src/slopometry/summoner/services/llm_service.py @@ -4,16 +4,7 @@ from pathlib import Path from slopometry.core.database import EventDatabase -from slopometry.core.models import UserStoryEntry from slopometry.core.settings import settings -from slopometry.summoner.services.llm_wrapper import ( - calculate_stride_size, - get_commit_diff, - get_feature_boundaries, - get_user_story_agent, - get_user_story_prompt, - resolve_commit_reference, -) class LLMService: @@ -30,6 +21,15 @@ def generate_user_stories_from_commits( Returns: Tuple of (successful_generations, error_messages) """ + from slopometry.core.models import UserStoryEntry + from slopometry.summoner.services.llm_wrapper import ( + calculate_stride_size, + get_commit_diff, + get_user_story_agent, + get_user_story_prompt, + resolve_commit_reference, + ) + original_dir = os.getcwd() error_messages: list[str] = [] @@ -74,6 +74,7 @@ def generate_user_stories_from_commits( def get_feature_boundaries(self, repo_path: Path, limit: int = 20) -> list: """Get detected feature boundaries from merge commits.""" + from slopometry.summoner.services.llm_wrapper import get_feature_boundaries cached_features = self.db.get_feature_boundaries(repo_path) if cached_features: @@ -114,6 +115,8 @@ def prepare_features_data_for_display(self, features: list) -> list[dict]: def get_commit_info_for_display(self, base_commit: str, head_commit: str) -> dict: """Get commit information for display purposes.""" + from slopometry.summoner.services.llm_wrapper import calculate_stride_size, resolve_commit_reference + try: resolved_base = resolve_commit_reference(base_commit) resolved_head = resolve_commit_reference(head_commit) diff --git a/tests/test_save_transcript.py b/tests/test_save_transcript.py index ea1b5d7..11f9098 100644 --- a/tests/test_save_transcript.py +++ b/tests/test_save_transcript.py @@ -116,7 +116,7 @@ def test_save_transcript__creates_session_directory_structure(self, tmp_path): ) with ( - patch("slopometry.solo.cli.commands.SessionService") as mock_service_class, + patch("slopometry.solo.services.session_service.SessionService") as mock_service_class, patch("slopometry.solo.cli.commands._find_plan_names_from_transcript", return_value=[]), patch("slopometry.solo.cli.commands._find_session_todos", return_value=[]), ): @@ -158,7 +158,7 @@ def test_save_transcript__copies_plans_from_transcript_references(self, tmp_path ) with ( - patch("slopometry.solo.cli.commands.SessionService") as mock_service_class, + patch("slopometry.solo.services.session_service.SessionService") as mock_service_class, patch.object(Path, "home", return_value=tmp_path), patch("slopometry.solo.cli.commands._find_session_todos", return_value=[]), ): @@ -200,7 +200,7 @@ def test_save_transcript__copies_todos_matching_session_id(self, tmp_path): ) with ( - patch("slopometry.solo.cli.commands.SessionService") as mock_service_class, + patch("slopometry.solo.services.session_service.SessionService") as mock_service_class, patch.object(Path, "home", return_value=tmp_path), patch("slopometry.solo.cli.commands._find_plan_names_from_transcript", return_value=[]), ): @@ -236,7 +236,7 @@ def test_save_transcript__handles_missing_plans_gracefully(self, tmp_path): ) with ( - patch("slopometry.solo.cli.commands.SessionService") as mock_service_class, + patch("slopometry.solo.services.session_service.SessionService") as mock_service_class, patch.object(Path, "home", return_value=tmp_path), patch("slopometry.solo.cli.commands._find_session_todos", return_value=[]), ): @@ -257,7 +257,7 @@ def test_save_transcript__shows_error_when_session_not_found(self): """Test error handling when session doesn't exist.""" session_id = "non-existent" - with patch("slopometry.solo.cli.commands.SessionService") as mock_service_class: + with patch("slopometry.solo.services.session_service.SessionService") as mock_service_class: mock_service = Mock() mock_service_class.return_value = mock_service mock_service.get_session_statistics.return_value = None @@ -278,7 +278,7 @@ def test_save_transcript__shows_error_when_no_transcript_path(self): transcript_path=None, ) - with patch("slopometry.solo.cli.commands.SessionService") as mock_service_class: + with patch("slopometry.solo.services.session_service.SessionService") as mock_service_class: mock_service = Mock() mock_service_class.return_value = mock_service mock_service.get_session_statistics.return_value = mock_stats @@ -305,7 +305,7 @@ def test_save_transcript__uses_latest_session_when_no_id_provided(self, tmp_path ) with ( - patch("slopometry.solo.cli.commands.SessionService") as mock_service_class, + patch("slopometry.solo.services.session_service.SessionService") as mock_service_class, patch("slopometry.solo.cli.commands._find_plan_names_from_transcript", return_value=[]), patch("slopometry.solo.cli.commands._find_session_todos", return_value=[]), ): @@ -338,7 +338,7 @@ def test_save_transcript__skips_confirmation_with_yes_flag(self, tmp_path): ) with ( - patch("slopometry.solo.cli.commands.SessionService") as mock_service_class, + patch("slopometry.solo.services.session_service.SessionService") as mock_service_class, patch("slopometry.solo.cli.commands._find_plan_names_from_transcript", return_value=[]), patch("slopometry.solo.cli.commands._find_session_todos", return_value=[]), ): @@ -356,7 +356,7 @@ def test_save_transcript__skips_confirmation_with_yes_flag(self, tmp_path): def test_save_transcript__shows_error_when_no_sessions_exist(self): """Test error handling when no sessions exist at all.""" - with patch("slopometry.solo.cli.commands.SessionService") as mock_service_class: + with patch("slopometry.solo.services.session_service.SessionService") as mock_service_class: mock_service = Mock() mock_service_class.return_value = mock_service mock_service.get_most_recent_session.return_value = None @@ -381,7 +381,7 @@ def test_save_transcript__cancels_when_user_declines_confirmation(self, tmp_path total_events=42, ) - with patch("slopometry.solo.cli.commands.SessionService") as mock_service_class: + with patch("slopometry.solo.services.session_service.SessionService") as mock_service_class: mock_service = Mock() mock_service_class.return_value = mock_service mock_service.get_most_recent_session.return_value = session_id From ae63da1d7d11a62aa99be89b05375a614bceeefd Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Wed, 7 Jan 2026 19:19:07 +0200 Subject: [PATCH 2/2] Fix bug in qpe project comparison. Add ant agent to wrappers. Comment clenaup in edited files --- .coverage | Bin 53248 -> 53248 bytes .env.summoner.example | 7 +- coverage.xml | 338 ++++++++++-------- pyproject.toml | 2 +- src/slopometry/core/database.py | 8 +- src/slopometry/core/migrations.py | 65 +++- src/slopometry/core/settings.py | 8 +- .../summoner/services/llm_wrapper.py | 17 +- tests/test_database.py | 75 +++- tests/test_llm_integration.py | 61 ++++ tests/test_migrations.py | 19 +- uv.lock | 2 +- 12 files changed, 402 insertions(+), 200 deletions(-) diff --git a/.coverage b/.coverage index 01280379dc2ff2f23a88cd87f8a76f6da230ebb5..c592316bf5f9ecfa51d0f1d581174b8d14ba742f 100644 GIT binary patch delta 214 zcmV;{04e`~paX!Q1F!~wM(qF(-VcutT@OJHTMicunGJ~zehqF7F$|mxfDCU81`Fp4 z$qTd#p$hH_q6&fva|!AR(+Rc-mI;ChcL`q!JP8^J0SN805fGsWll6#CBOxCI0SQDD zu5We-2mk;85`O6+gAqud;Iv;2cEqE65wDXkl@N&Q7zQDbk zdj;25u9aM!TvePOId5_9<($je$yvvl#_7VT#L3C=b+e$rN{-2IJN%UtKOs>j>a+jcW; X&g(tG$PY5v0g4&K7&kli?{@$IBauiB diff --git a/.env.summoner.example b/.env.summoner.example index 7a25408..c07eb21 100644 --- a/.env.summoner.example +++ b/.env.summoner.example @@ -18,9 +18,14 @@ SLOPOMETRY_LLM_PROXY_API_KEY=your-api-key SLOPOMETRY_LLM_RESPONSES_URL=https://your-proxy.example.com/responses # User Story Generation -# Available agents: gpt_oss_120b, gemini +# Available agents: gpt_oss_120b, gemini, minimax SLOPOMETRY_USER_STORY_AGENT=gpt_oss_120b +# Anthropic Provider (e.g. sglang with MiniMax-M2.1) +# Provides access to MiniMax models via custom Anthropic-compatible endpoints +SLOPOMETRY_ANTHROPIC_URL=https://your-sglang-endpoint.example.com +SLOPOMETRY_ANTHROPIC_API_KEY=your-anthropic-api-key + # Interactive Rating for Dataset Quality Control # Prompts you to rate generated user stories (1-5) SLOPOMETRY_INTERACTIVE_RATING_ENABLED=false diff --git a/coverage.xml b/coverage.xml index 875714e..a4e7804 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -16,7 +16,7 @@ - + @@ -609,7 +609,7 @@ - + @@ -1110,32 +1110,32 @@ - - - + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + + @@ -1903,7 +1903,7 @@ - + @@ -2007,73 +2007,90 @@ + - - + + - + - - - + + + + - + - - - - - + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - + - + + - - - + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3259,7 +3276,7 @@ - + @@ -3315,56 +3332,58 @@ + - + - - - - + + + + - - - - + + + + - - - + + - - + + + - + - + - + - - + + - + - - - + + + - + - - - + + + + @@ -5476,7 +5495,7 @@ - + @@ -6192,7 +6211,7 @@ - + @@ -6202,85 +6221,88 @@ + - - - - - - - + + + + + + - - - - - - - - - - - + + + + + + + + - - + + + + + + + - - - - - - - - - - - - + + + + + + + + - - - - - + + + + + - - - + + + + + - - - - - - + + + + + + + + - - - - - - - - - - + + + + + + + - - - - + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index fafdde7..1308f9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "slopometry" -version = "20260105-1" +version = "20260107-1" description = "Opinionated code quality metrics for code agents and humans" readme = "README.md" requires-python = ">=3.13" diff --git a/src/slopometry/core/database.py b/src/slopometry/core/database.py index 2c4edea..1a99559 100644 --- a/src/slopometry/core/database.py +++ b/src/slopometry/core/database.py @@ -1644,8 +1644,8 @@ def save_baseline(self, baseline: RepoBaseline) -> None: def save_leaderboard_entry(self, entry: LeaderboardEntry) -> None: """Save or update a leaderboard entry. - Uses UPSERT semantics - if an entry for this project/commit exists, - it will be updated with the new values. + Uses UPSERT semantics - if an entry for this project_path exists, + it will be updated with the new values (including new commit info). """ with self._get_db_connection() as conn: conn.execute( @@ -1655,8 +1655,10 @@ def save_leaderboard_entry(self, entry: LeaderboardEntry) -> None: measured_at, qpe_score, mi_normalized, smell_penalty, adjusted_quality, effort_factor, total_effort, metrics_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(project_path, commit_sha_full) DO UPDATE SET + ON CONFLICT(project_path) DO UPDATE SET project_name = excluded.project_name, + commit_sha_short = excluded.commit_sha_short, + commit_sha_full = excluded.commit_sha_full, measured_at = excluded.measured_at, qpe_score = excluded.qpe_score, mi_normalized = excluded.mi_normalized, diff --git a/src/slopometry/core/migrations.py b/src/slopometry/core/migrations.py index 1c7b268..0993a60 100644 --- a/src/slopometry/core/migrations.py +++ b/src/slopometry/core/migrations.py @@ -215,7 +215,6 @@ def description(self) -> str: def up(self, conn: sqlite3.Connection) -> None: """Add QPE columns to experiment_progress table.""" - # Check if table exists first cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='experiment_progress'") if not cursor.fetchone(): return # Table doesn't exist yet, skip migration @@ -233,7 +232,6 @@ def up(self, conn: sqlite3.Connection) -> None: if "duplicate column name" not in str(e).lower(): raise - # Add index for QPE score queries conn.execute("CREATE INDEX IF NOT EXISTS idx_progress_qpe ON experiment_progress(experiment_id, qpe_score)") @@ -269,10 +267,68 @@ def up(self, conn: sqlite3.Connection) -> None: ) """) - # Index for ranking queries conn.execute("CREATE INDEX IF NOT EXISTS idx_leaderboard_qpe ON qpe_leaderboard(qpe_score DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_leaderboard_project ON qpe_leaderboard(project_path, measured_at)") + + +class Migration008FixLeaderboardUniqueConstraint(Migration): + """Fix leaderboard unique constraint to allow updating entries when commit changes. + + The original constraint UNIQUE(project_path, commit_sha_full) prevented updates + when a project's commit changed (e.g., after git pull). This migration changes + the constraint to just UNIQUE(project_path) so --append refreshes existing entries. + """ + + @property + def version(self) -> str: + return "008" + + @property + def description(self) -> str: + return "Fix qpe_leaderboard unique constraint to project_path only" + + def up(self, conn: sqlite3.Connection) -> None: + """Recreate qpe_leaderboard with corrected unique constraint.""" + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='qpe_leaderboard'") + if not cursor.fetchone(): + return + + conn.execute(""" + CREATE TABLE IF NOT EXISTS qpe_leaderboard_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_name TEXT NOT NULL, + project_path TEXT NOT NULL UNIQUE, + commit_sha_short TEXT NOT NULL, + commit_sha_full TEXT NOT NULL, + measured_at TEXT NOT NULL, + qpe_score REAL NOT NULL, + mi_normalized REAL NOT NULL, + smell_penalty REAL NOT NULL, + adjusted_quality REAL NOT NULL, + effort_factor REAL NOT NULL, + total_effort REAL NOT NULL, + metrics_json TEXT NOT NULL + ) + """) + + conn.execute(""" + INSERT OR REPLACE INTO qpe_leaderboard_new ( + project_name, project_path, commit_sha_short, commit_sha_full, + measured_at, qpe_score, mi_normalized, smell_penalty, + adjusted_quality, effort_factor, total_effort, metrics_json + ) + SELECT project_name, project_path, commit_sha_short, commit_sha_full, + measured_at, qpe_score, mi_normalized, smell_penalty, + adjusted_quality, effort_factor, total_effort, metrics_json + FROM qpe_leaderboard + GROUP BY project_path + HAVING measured_at = MAX(measured_at) + """) - # Index for project history queries + conn.execute("DROP TABLE qpe_leaderboard") + conn.execute("ALTER TABLE qpe_leaderboard_new RENAME TO qpe_leaderboard") + + conn.execute("CREATE INDEX IF NOT EXISTS idx_leaderboard_qpe ON qpe_leaderboard(qpe_score DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_leaderboard_project ON qpe_leaderboard(project_path, measured_at)") @@ -289,6 +345,7 @@ def __init__(self, db_path: Path): Migration005AddGalenRateColumns(), Migration006AddQPEColumns(), Migration007AddQPELeaderboard(), + Migration008FixLeaderboardUniqueConstraint(), ] @contextmanager diff --git a/src/slopometry/core/settings.py b/src/slopometry/core/settings.py index 1e5b25a..c593748 100644 --- a/src/slopometry/core/settings.py +++ b/src/slopometry/core/settings.py @@ -6,7 +6,7 @@ from pathlib import Path from dotenv import dotenv_values -from pydantic import Field, field_validator, model_validator +from pydantic import Field, SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -91,6 +91,10 @@ def _ensure_global_config_dir() -> None: llm_proxy_url: str = "" llm_proxy_api_key: str = "" llm_responses_url: str = "" + anthropic_url: str = Field( + default="", description="Base URL for Anthropic-compatible API endpoint (e.g. sglang MiniMax endpoint)" + ) + anthropic_api_key: SecretStr = Field(default=SecretStr(""), description="API key for Anthropic-compatible provider") interactive_rating_enabled: bool = False hf_token: str = "" @@ -103,7 +107,7 @@ def _ensure_global_config_dir() -> None: user_story_agent: str = Field( default="gpt_oss_120b", - description="Agent to use for user story generation. Options: gpt_oss_120b, gemini", + description="Agent to use for user story generation. Options: gpt_oss_120b, gemini, minimax", ) enable_working_at_microsoft: bool = Field( diff --git a/src/slopometry/summoner/services/llm_wrapper.py b/src/slopometry/summoner/services/llm_wrapper.py index 2fa6307..305f3e2 100644 --- a/src/slopometry/summoner/services/llm_wrapper.py +++ b/src/slopometry/summoner/services/llm_wrapper.py @@ -5,7 +5,9 @@ logger = logging.getLogger(__name__) from pydantic_ai import Agent +from pydantic_ai.models.anthropic import AnthropicModel from pydantic_ai.models.openai import OpenAIChatModel, OpenAIResponsesModel, OpenAIResponsesModelSettings +from pydantic_ai.providers.anthropic import AnthropicProvider from pydantic_ai.providers.openai import OpenAIProvider from slopometry.core.models import FeatureBoundary, MergeCommit @@ -22,16 +24,19 @@ def __init__(self): ) -def _create_providers() -> tuple[OpenAIProvider, OpenAIProvider]: +def _create_providers() -> tuple[OpenAIProvider, OpenAIProvider, AnthropicProvider]: """Create LLM providers. Only called when offline_mode is disabled.""" llm_gateway = OpenAIProvider(base_url=settings.llm_proxy_url, api_key=settings.llm_proxy_api_key) responses_api_gateway = OpenAIProvider(base_url=settings.llm_responses_url, api_key=settings.llm_proxy_api_key) - return llm_gateway, responses_api_gateway + anthropic_gateway = AnthropicProvider( + base_url=settings.anthropic_url, api_key=settings.anthropic_api_key.get_secret_value() + ) + return llm_gateway, responses_api_gateway, anthropic_gateway def _create_agents() -> dict[str, Agent]: """Create all available agents. Only called when offline_mode is disabled.""" - llm_gateway, responses_api_gateway = _create_providers() + llm_gateway, responses_api_gateway, anthropic_gateway = _create_providers() return { "gpt_oss_120b": Agent( @@ -50,6 +55,12 @@ def _create_agents() -> dict[str, Agent]: name="gemini", model=OpenAIChatModel(model_name="gemini-3-pro-preview", provider=llm_gateway), ), + "minimax": Agent( + name="minimax", + model=AnthropicModel("minimax:MiniMax-M1.1", provider=anthropic_gateway), + retries=2, + end_strategy="exhaustive", + ), } diff --git a/tests/test_database.py b/tests/test_database.py index 168968d..d56bdfc 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1,21 +1,20 @@ -"""Test user story export functionality.""" +"""Test database functionality.""" import tempfile +from datetime import datetime from pathlib import Path from slopometry.core.database import EventDatabase -from slopometry.core.models import UserStoryEntry +from slopometry.core.models import LeaderboardEntry, UserStoryEntry def test_user_story_export_functionality(): """Test exporting user stories with existing or minimal test data.""" db = EventDatabase() - # Check if we have existing user story entries stats = db.get_user_story_stats() if stats["total_entries"] == 0: - # Create a minimal test entry if none exist test_entry = UserStoryEntry( base_commit="test-base", head_commit="test-head", @@ -29,26 +28,21 @@ def test_user_story_export_functionality(): ) db.save_user_story_entry(test_entry) - # Test export functionality with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as tmp: output_path = Path(tmp.name) try: - # Export the user stories count = db.export_user_stories(output_path) - # Verify export worked assert count >= 1, f"Expected at least 1 entry, got {count}" assert output_path.exists(), "Export file was not created" assert output_path.stat().st_size > 0, "Export file is empty" - # Verify we can read it back with pandas if available try: import pandas as pd df = pd.read_parquet(output_path) - # Check structure expected_columns = [ "id", "created_at", @@ -63,16 +57,12 @@ def test_user_story_export_functionality(): "repository_path", ] assert all(col in df.columns for col in expected_columns) - - # Check we have data assert len(df) >= 1 except ImportError: - # If pandas not available, just check file exists pass finally: - # Cleanup if output_path.exists(): output_path.unlink() @@ -81,7 +71,6 @@ def test_user_story_stats(): """Test user story statistics calculation.""" db = EventDatabase() - # Get stats (should have entries from previous test or real usage) stats = db.get_user_story_stats() assert stats["total_entries"] >= 0 @@ -92,19 +81,69 @@ def test_user_story_stats(): def test_user_story_generation_cli_integration(): - """Test that the CLI command for generating user story entries works.""" + """Test that the CLI command for generating user story entries works. + + Note: Does not run the actual command as it requires LLM access. + """ from click.testing import CliRunner from slopometry.cli import cli runner = CliRunner() - # Test the userstorify command help (now under summoner subcommand) result = runner.invoke(cli, ["summoner", "userstorify", "--help"]) assert result.exit_code == 0 assert "Generate user stories from commits using configured AI agents" in result.output assert "--base-commit" in result.output assert "--head-commit" in result.output - # Note: We don't run the actual command here as it requires LLM access - # and would be slow/expensive. The command is tested manually. + +def test_leaderboard_upsert__updates_existing_project_on_new_commit(): + """Test that saving a leaderboard entry with same project_path but different commit updates the entry.""" + with tempfile.TemporaryDirectory() as tmp_dir: + db = EventDatabase(db_path=Path(tmp_dir) / "test.db") + + project_path = "/test/project" + entry_v1 = LeaderboardEntry( + project_name="test-project", + project_path=project_path, + commit_sha_short="abc1234", + commit_sha_full="abc1234567890", + measured_at=datetime(2023, 1, 1), + qpe_score=0.5, + mi_normalized=0.6, + smell_penalty=0.1, + adjusted_quality=0.7, + effort_factor=1.2, + total_effort=1000.0, + metrics_json="{}", + ) + db.save_leaderboard_entry(entry_v1) + + leaderboard = db.get_leaderboard() + assert len(leaderboard) == 1 + assert leaderboard[0].commit_sha_short == "abc1234" + assert leaderboard[0].qpe_score == 0.5 + + entry_v2 = LeaderboardEntry( + project_name="test-project", + project_path=project_path, + commit_sha_short="def5678", + commit_sha_full="def5678901234", + measured_at=datetime(2024, 6, 1), + qpe_score=0.8, + mi_normalized=0.7, + smell_penalty=0.05, + adjusted_quality=0.85, + effort_factor=1.1, + total_effort=1200.0, + metrics_json='{"updated": true}', + ) + db.save_leaderboard_entry(entry_v2) + + leaderboard = db.get_leaderboard() + assert len(leaderboard) == 1, "Should update existing entry, not create duplicate" + assert leaderboard[0].commit_sha_short == "def5678" + assert leaderboard[0].commit_sha_full == "def5678901234" + assert leaderboard[0].qpe_score == 0.8 + assert leaderboard[0].measured_at == datetime(2024, 6, 1) diff --git a/tests/test_llm_integration.py b/tests/test_llm_integration.py index dac9d5a..9c74de9 100644 --- a/tests/test_llm_integration.py +++ b/tests/test_llm_integration.py @@ -82,3 +82,64 @@ def test_get_user_story_agent__returns_configured_agent(): assert agent is not None assert agent.name == settings.user_story_agent + + +@skip_without_integration_flag +def test_minimax__returns_response_when_given_simple_prompt(agents): + """Test that minimax agent returns a response for a simple prompt.""" + if "minimax" not in agents: + pytest.skip("minimax agent not configured") + + agent = agents["minimax"] + prompt = "What is 3 + 5? Reply with just the number." + + result = agent.run_sync(prompt) + + assert result is not None + assert result.output is not None + assert len(result.output) > 0 + assert "8" in result.output + + +@skip_without_integration_flag +def test_minimax__handles_code_analysis_prompt(agents): + """Test that minimax can analyze a simple code diff.""" + if "minimax" not in agents: + pytest.skip("minimax agent not configured") + + agent = agents["minimax"] + prompt = """Analyze this Python code change and describe what it does in one sentence: + +```diff +- def add(a, b): +- return a + b ++ def add(a: int, b: int) -> int: ++ return a + b +```""" + + result = agent.run_sync(prompt) + + assert result is not None + assert result.output is not None + assert len(result.output) > 5 + + +@skip_without_integration_flag +def test_minimax__returns_valid_usage_with_token_info(agents): + """Test that minimax returns a response with usage info (may be empty for some providers).""" + if "minimax" not in agents: + pytest.skip("minimax agent not configured") + + agent = agents["minimax"] + prompt = "Write a short one-sentence greeting." + + result = agent.run_sync(prompt) + + assert result is not None + assert result.output is not None + assert len(result.output) > 0 + + # Check that usage attribute is present (may be empty or have non-standard fields for some providers) + assert result.usage is not None, "Expected usage attribute to be present in response" + # Verify output indicates successful API call + assert "MiniMax" in result.output or len(result.output) > 5 diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 7eddb92..2c411a5 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -27,7 +27,7 @@ def test_migration_001__adds_transcript_path_column_and_index(self): applied = runner.run_migrations() - assert len(applied) == 7 + assert len(applied) == 8 assert any("001" in migration and "transcript_path" in migration for migration in applied) assert any("002" in migration and "code quality cache" in migration for migration in applied) assert any("003" in migration and "working_tree_hash" in migration for migration in applied) @@ -35,6 +35,7 @@ def test_migration_001__adds_transcript_path_column_and_index(self): assert any("005" in migration and "oldest_commit" in migration for migration in applied) assert any("006" in migration and "qpe_score" in migration for migration in applied) assert any("007" in migration and "qpe_leaderboard" in migration for migration in applied) + assert any("008" in migration and "unique constraint" in migration for migration in applied) with runner._get_db_connection() as conn: cursor = conn.execute("PRAGMA table_info(hook_events)") @@ -64,12 +65,12 @@ def test_migration_runner__idempotent_execution(self): applied_first = runner.run_migrations() applied_second = runner.run_migrations() - assert len(applied_first) == 7 + assert len(applied_first) == 8 assert len(applied_second) == 0 status = runner.get_migration_status() - assert status["total"] == 7 - assert len(status["applied"]) == 7 + assert status["total"] == 8 + assert len(status["applied"]) == 8 assert len(status["pending"]) == 0 def test_migration_runner__tracks_migration_status(self): @@ -94,12 +95,12 @@ def test_migration_runner__tracks_migration_status(self): status_after = runner.get_migration_status() - assert status_before["total"] == 7 + assert status_before["total"] == 8 assert len(status_before["applied"]) == 0 - assert len(status_before["pending"]) == 7 + assert len(status_before["pending"]) == 8 - assert status_after["total"] == 7 - assert len(status_after["applied"]) == 7 + assert status_after["total"] == 8 + assert len(status_after["applied"]) == 8 assert len(status_after["pending"]) == 0 migration_001 = next((m for m in status_after["applied"] if m["version"] == "001"), None) @@ -125,7 +126,7 @@ def test_migration_001__handles_existing_column_gracefully(self): applied = runner.run_migrations() - assert len(applied) == 7 + assert len(applied) == 8 with runner._get_db_connection() as conn: cursor = conn.execute("PRAGMA table_info(hook_events)") diff --git a/uv.lock b/uv.lock index e71d10e..358cf8a 100644 --- a/uv.lock +++ b/uv.lock @@ -2836,7 +2836,7 @@ wheels = [ [[package]] name = "slopometry" -version = "20260105.post1" +version = "20260107.post1" source = { editable = "." } dependencies = [ { name = "click" },