buysmarter 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. buysmarter/__init__.py +3 -0
  2. buysmarter/api/__init__.py +0 -0
  3. buysmarter/api/main.py +11 -0
  4. buysmarter/cli/__init__.py +0 -0
  5. buysmarter/cli/main.py +655 -0
  6. buysmarter/cli/report.py +259 -0
  7. buysmarter/config.py +49 -0
  8. buysmarter/db/__init__.py +0 -0
  9. buysmarter/db/base.py +39 -0
  10. buysmarter/db/models.py +123 -0
  11. buysmarter/db/repo.py +268 -0
  12. buysmarter/knowledge/__init__.py +9 -0
  13. buysmarter/knowledge/brands.py +247 -0
  14. buysmarter/knowledge/generate.py +205 -0
  15. buysmarter/knowledge/ingredients.py +131 -0
  16. buysmarter/knowledge/models_book.py +145 -0
  17. buysmarter/knowledge/store.py +341 -0
  18. buysmarter/llm/__init__.py +5 -0
  19. buysmarter/llm/adapter.py +448 -0
  20. buysmarter/llm/cache.py +188 -0
  21. buysmarter/llm/canonicalize.py +173 -0
  22. buysmarter/llm/ingredients.py +182 -0
  23. buysmarter/llm/jev.py +68 -0
  24. buysmarter/llm/models.py +35 -0
  25. buysmarter/llm/prompts.py +127 -0
  26. buysmarter/llm/relevance.py +192 -0
  27. buysmarter/llm/review_summary.py +383 -0
  28. buysmarter/logsetup.py +92 -0
  29. buysmarter/mcp_server/__init__.py +0 -0
  30. buysmarter/mcp_server/__main__.py +9 -0
  31. buysmarter/mcp_server/server.py +259 -0
  32. buysmarter/memory/__init__.py +0 -0
  33. buysmarter/memory/prefs.py +81 -0
  34. buysmarter/normalize/__init__.py +0 -0
  35. buysmarter/normalize/candidate.py +119 -0
  36. buysmarter/ranking/__init__.py +0 -0
  37. buysmarter/ranking/delivery.py +91 -0
  38. buysmarter/ranking/engine.py +1317 -0
  39. buysmarter/ranking/model_tokens.py +48 -0
  40. buysmarter/ranking/models.py +160 -0
  41. buysmarter/ranking/review_requirements.py +62 -0
  42. buysmarter/ranking/review_trust.py +311 -0
  43. buysmarter/ranking/spec_conflicts.py +76 -0
  44. buysmarter/ranking/used_defects.py +55 -0
  45. buysmarter/ranking/velocity.py +42 -0
  46. buysmarter/services/__init__.py +0 -0
  47. buysmarter/services/phone_specs.py +202 -0
  48. buysmarter/services/recommend.py +2107 -0
  49. buysmarter-0.1.2.dist-info/METADATA +238 -0
  50. buysmarter-0.1.2.dist-info/RECORD +53 -0
  51. buysmarter-0.1.2.dist-info/WHEEL +4 -0
  52. buysmarter-0.1.2.dist-info/entry_points.txt +2 -0
  53. buysmarter-0.1.2.dist-info/licenses/LICENSE +21 -0
buysmarter/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """buy-smarter: AI-powered shopping assistant."""
2
+
3
+ __version__ = "0.1.0"
File without changes
buysmarter/api/main.py ADDED
@@ -0,0 +1,11 @@
1
+ """FastAPI application entry point."""
2
+
3
+ from fastapi import FastAPI
4
+
5
+ app = FastAPI(title="buy-smarter", version="0.1.0")
6
+
7
+
8
+ @app.get("/health")
9
+ async def health() -> dict[str, str]:
10
+ """Liveness probe."""
11
+ return {"status": "ok"}
File without changes
buysmarter/cli/main.py ADDED
@@ -0,0 +1,655 @@
1
+ """Typer CLI entry point for buy-smarter."""
2
+
3
+ import asyncio
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ import typer
10
+ from market_sources.ozon.browser import OzonBrowser, ozon_state_path
11
+ from market_sources.ozon.source import search_path
12
+ from rich.console import Console
13
+ from rich.markup import escape
14
+ from rich.panel import Panel
15
+ from rich.table import Table
16
+
17
+ from buysmarter.cli.report import format_rejected_reasons, save_report
18
+ from buysmarter.config import get_settings
19
+ from buysmarter.knowledge.brands import BrandBook
20
+ from buysmarter.knowledge.generate import (
21
+ EXTRACT_KNOWLEDGE_MODEL,
22
+ GENERATE_KNOWLEDGE_MODEL,
23
+ extract_category_knowledge,
24
+ generate_category_knowledge,
25
+ )
26
+ from buysmarter.knowledge.ingredients import IngredientBook, merge_ingredient_books
27
+ from buysmarter.knowledge.models_book import ModelBook
28
+ from buysmarter.knowledge.store import (
29
+ SPEC_DRIVEN,
30
+ get_stored_books,
31
+ merge_brand_books,
32
+ merge_model_books,
33
+ resolve_composition,
34
+ save_knowledge,
35
+ )
36
+ from buysmarter.llm.review_summary import ReviewSummary
37
+ from buysmarter.logsetup import new_run_id, setup_logging
38
+ from buysmarter.ranking.engine import is_barred
39
+ from buysmarter.ranking.models import QueryConstraints, ScoredCandidate
40
+ from buysmarter.services.recommend import (
41
+ CategoryRefresh,
42
+ NoSubstanceMatch,
43
+ ProductHistory,
44
+ RecommendResult,
45
+ UsedListing,
46
+ )
47
+ from buysmarter.services.recommend import parse_request as run_parse_request
48
+ from buysmarter.services.recommend import product_history as run_product_history
49
+ from buysmarter.services.recommend import recommend as run_recommend
50
+ from buysmarter.services.recommend import refresh_tracked as run_refresh_tracked
51
+
52
+ _DEFAULT_TOP = 8
53
+
54
+ app = typer.Typer(help="buy-smarter: AI-powered shopping assistant.")
55
+ console = Console()
56
+
57
+ _ALL_SOURCES = ("kaspi", "ozon", "olx")
58
+
59
+
60
+ @app.callback()
61
+ def _main() -> None:
62
+ """buy-smarter: AI-powered shopping assistant (keeps subcommands named)."""
63
+ setup_logging()
64
+ new_run_id()
65
+
66
+
67
+ _QUERY_HELP = 'Natural-language request, e.g. "нужен ирригатор, IPX7, до 40000 тг, Алматы"'
68
+ _ROLE_LABELS = {
69
+ "best_value": "Best value",
70
+ "best_budget": "Best budget",
71
+ "best_premium": "Best premium",
72
+ }
73
+
74
+
75
+ def _render_constraints(c: QueryConstraints, city_defaulted: bool = False) -> None:
76
+ city_note = " (по умолчанию)" if city_defaulted else ""
77
+ lines = [
78
+ f"[bold]Category:[/bold] {c.category}",
79
+ f"[bold]City:[/bold] {c.city or '-'}{city_note}",
80
+ f"[bold]Budget:[/bold] {f'{c.budget_max:,} ₸' if c.budget_max else 'без лимита'}",
81
+ ]
82
+ if c.required_features:
83
+ lines.append(f"[bold]Required:[/bold] {', '.join(c.required_features)}")
84
+ if c.excluded_brands:
85
+ lines.append(f"[bold]Excluded:[/bold] {', '.join(c.excluded_brands)}")
86
+ console.print(Panel("\n".join(lines), title="Parsed request", expand=False))
87
+
88
+
89
+ def _render_best(best: ScoredCandidate) -> None:
90
+ cand = best.candidate
91
+ # M4: `best` can be a barred (suspicious-price/dup-gap/mimicry/
92
+ # counterfeit-verdict) candidate ONLY via assemble_recommendation's
93
+ # all-barred fallback — nothing trustworthy was left to prefer. That must
94
+ # be visible, not silently presented as an endorsed pick.
95
+ barred = is_barred(best)
96
+ title = f"⚠ {cand.title}" if barred else cand.title
97
+ header = (
98
+ f"🥇 [bold]{escape(title)}[/bold] [dim]\\[{escape(cand.source)}][/dim]\n"
99
+ f"Score: [bold]{best.total_score:.0f}[/bold]/100 "
100
+ f"Confidence: [bold]{best.confidence:.0%}[/bold]\n"
101
+ f"Price: [bold]{cand.price:,} ₸[/bold]"
102
+ )
103
+ if barred:
104
+ header += "\n[bold red]все кандидаты под подозрением — проверьте вручную[/bold red]"
105
+ if best.review_penalty > 0:
106
+ header += f"\n[red]Penalty: отзывы (−{best.review_penalty:.0f})[/red]"
107
+ if cand.best_seller:
108
+ header += f"\nПродавец: {escape(cand.best_seller)}"
109
+ if cand.url:
110
+ header += f"\n{escape(cand.url)}"
111
+ console.print(Panel(header, title="Best pick", expand=False))
112
+
113
+ table = Table(show_header=True, header_style="bold")
114
+ table.add_column("Axis")
115
+ table.add_column("Score", justify="right")
116
+ table.add_column("Why")
117
+ for axis in best.axes:
118
+ table.add_row(
119
+ escape(axis.name), f"{axis.score:.1f}/{axis.max_score:.0f}", escape(axis.explanation)
120
+ )
121
+ console.print(table)
122
+
123
+ if best.pros:
124
+ console.print("[green]+ " + escape(" + ".join(best.pros)) + "[/green]")
125
+ if best.cons:
126
+ console.print("[red]- " + escape(" - ".join(best.cons)) + "[/red]")
127
+
128
+
129
+ def _render_review_summary(summary: ReviewSummary | None) -> None:
130
+ """Render the AI review summary panel for the best pick (docs/INIT_DATA.md)."""
131
+ if summary is None:
132
+ return
133
+ lines = [f"[bold red]⚠ {escape(f)}[/bold red]" for f in summary.red_flags]
134
+ lines += [f"[green]✓[/green] {escape(p)}" for p in summary.pros]
135
+ lines += [f"[red]✗[/red] {escape(c)}" for c in summary.cons]
136
+ lines += [f"[yellow]«{escape(c)}»[/yellow]" for c in summary.common_complaints]
137
+ if summary.verdict_line:
138
+ lines.append(f"\n[bold]{escape(summary.verdict_line)}[/bold]")
139
+ if not lines:
140
+ return
141
+ console.print(Panel("\n".join(lines), title="Отзывы кратко", expand=False))
142
+
143
+
144
+ def _product_cell(title: str, url: str | None) -> str:
145
+ """Product name, with the URL on a second dim line when present."""
146
+ return f"{escape(title)}\n[dim]{escape(url)}[/dim]" if url else escape(title)
147
+
148
+
149
+ def _render_alternatives(alts: dict[str, ScoredCandidate]) -> None:
150
+ seen: set[str] = set()
151
+ table = Table(title="Alternatives", show_header=True, header_style="bold")
152
+ table.add_column("Role")
153
+ table.add_column("Source")
154
+ table.add_column("Product")
155
+ table.add_column("Price", justify="right")
156
+ table.add_column("Score", justify="right")
157
+ for role, scored in alts.items():
158
+ key = scored.candidate.source_product_id
159
+ if key in seen:
160
+ continue
161
+ seen.add(key)
162
+ table.add_row(
163
+ _ROLE_LABELS.get(role, role),
164
+ scored.candidate.source,
165
+ _product_cell(scored.candidate.title, scored.candidate.url),
166
+ f"{scored.candidate.price:,} ₸",
167
+ f"{scored.total_score:.0f}",
168
+ )
169
+ console.print(table)
170
+
171
+
172
+ def _tier_cell(c) -> str:
173
+ """Tier column: a mimic candidate flags the counterfeit signal instead of
174
+ the (usually empty) tier — "-" alone would hide the more useful fact."""
175
+ if c.mimic_brand is not None:
176
+ return f"подделка под {escape(c.mimic_brand)}?"
177
+ return escape(c.brand_tier or "-")
178
+
179
+
180
+ def _render_top(scored: list[ScoredCandidate], top: int) -> None:
181
+ ranked = scored[:top]
182
+ if not ranked:
183
+ return
184
+ title = f"Top candidates (top {len(ranked)})"
185
+ table = Table(title=title, show_header=True, header_style="bold")
186
+ table.add_column("#", justify="right")
187
+ table.add_column("Source")
188
+ table.add_column("Product")
189
+ table.add_column("Price", justify="right")
190
+ table.add_column("Score", justify="right")
191
+ table.add_column("Tier")
192
+ for i, s in enumerate(ranked, start=1):
193
+ c = s.candidate
194
+ # A barred candidate (suspicious price, dup-price-gap, mimicry, or a
195
+ # review-summary counterfeit verdict) stays visible in this table but
196
+ # gets a visible "⚠" marker — it can never win best/alternative roles
197
+ # (docs/PLAN.md "Ranking-фиксы"), and the table shouldn't hide that.
198
+ title_text = f"⚠ {c.title}" if is_barred(s) else c.title
199
+ table.add_row(
200
+ str(i),
201
+ c.source,
202
+ _product_cell(title_text, c.url),
203
+ f"{c.price:,} ₸",
204
+ f"{s.total_score:.0f}",
205
+ _tier_cell(c),
206
+ )
207
+ console.print(table)
208
+
209
+
210
+ def _render_used(used: list[UsedListing]) -> None:
211
+ if not used:
212
+ return
213
+ table = Table(title="Б/у на OLX", show_header=True, header_style="bold")
214
+ table.add_column("Listing")
215
+ table.add_column("Price", justify="right")
216
+ table.add_column("Saving vs new", justify="right")
217
+ table.add_column("City")
218
+ table.add_column("Con")
219
+ for listing in used:
220
+ title = f"{listing.title} → {listing.canonical}" if listing.canonical else listing.title
221
+ saving_cell = "—" if listing.standalone else f"-{listing.saving:,} ₸"
222
+ table.add_row(
223
+ _product_cell(title, listing.url),
224
+ f"{listing.price:,} ₸",
225
+ saving_cell,
226
+ escape(listing.city or "-"),
227
+ escape(listing.con),
228
+ )
229
+ console.print(table)
230
+
231
+
232
+ def _render(result: RecommendResult, top: int) -> None:
233
+ _render_constraints(result.constraints, result.city_defaulted)
234
+ _render_best(result.recommendation.best)
235
+ _render_review_summary(result.review_summary)
236
+ _render_alternatives(result.recommendation.alternatives)
237
+ _render_top(result.recommendation.ranked, top)
238
+ _render_used(result.used_listings)
239
+ if result.recommendation.rejected_count:
240
+ console.print(
241
+ f"[dim]{result.recommendation.rejected_count} candidate(s) "
242
+ "rejected by hard filters.[/dim]"
243
+ )
244
+ if result.recommendation.rejected_reasons:
245
+ breakdown = format_rejected_reasons(result.recommendation.rejected_reasons)
246
+ console.print(f"[dim]Отсеяно: {breakdown}[/dim]")
247
+
248
+
249
+ def _render_no_substance(exc: NoSubstanceMatch) -> None:
250
+ """Honest empty-pool output: the message + rejected-by-substance alternatives.
251
+
252
+ Deliberately no best-pick panel — a product lacking the requested active
253
+ substance must never be presented as a recommendation.
254
+ """
255
+ console.print(f"[yellow]{escape(exc.message())}[/yellow]")
256
+ if not exc.alternatives:
257
+ return
258
+ table = Table(
259
+ title="Ближайшие альтернативы (без требуемого вещества)",
260
+ show_header=True,
261
+ header_style="bold",
262
+ )
263
+ table.add_column("Product")
264
+ table.add_column("Price", justify="right")
265
+ table.add_column("Rating", justify="right")
266
+ for cand in exc.alternatives:
267
+ rating = f"{cand.rating:.1f}" if cand.rating is not None else "-"
268
+ table.add_row(_product_cell(cand.title, cand.url), f"{cand.price:,} ₸", rating)
269
+ console.print(table)
270
+
271
+
272
+ @app.command()
273
+ def recommend(
274
+ query: str = typer.Argument(..., help=_QUERY_HELP),
275
+ city: str | None = typer.Option(None, "--city", "-c", help="Override city."),
276
+ budget: int | None = typer.Option(None, "--budget", "-b", help="Override max price (KZT)."),
277
+ no_llm: bool = typer.Option(False, "--no-llm", help="Force keyless naive query parsing."),
278
+ trusted_only: bool = typer.Option(
279
+ False, "--trusted-only", help="Reject products from unknown/untrusted brands."
280
+ ),
281
+ sources: str = typer.Option(
282
+ ",".join(_ALL_SOURCES),
283
+ "--sources",
284
+ help="Comma-separated marketplaces to query, e.g. 'kaspi,ozon,olx' or 'kaspi'.",
285
+ ),
286
+ top: int = typer.Option(
287
+ _DEFAULT_TOP, "--top", min=1, help="How many ranked candidates to show in the table."
288
+ ),
289
+ no_save: bool = typer.Option(
290
+ False,
291
+ "--no-save",
292
+ help="Skip the markdown report (saved to ~/.local/share/buysmarter/reports/ by default).",
293
+ ),
294
+ save_path: str | None = typer.Option(
295
+ None, "--save-path", help="Explicit report path (implies saving even with --no-save)."
296
+ ),
297
+ no_review_summary: bool = typer.Option(
298
+ False, "--no-review-summary", help="Skip the AI review summary of the best pick."
299
+ ),
300
+ ) -> None:
301
+ """Recommend the best product for a natural-language request."""
302
+ selected = _parse_sources(sources)
303
+ try:
304
+ result = asyncio.run(
305
+ recommend_pipeline(
306
+ query,
307
+ city,
308
+ budget,
309
+ not no_llm,
310
+ trusted_only,
311
+ selected,
312
+ with_review_summary=not no_review_summary,
313
+ )
314
+ )
315
+ except NoSubstanceMatch as exc:
316
+ _render_no_substance(exc)
317
+ raise typer.Exit(code=1) from exc
318
+ except ValueError as exc:
319
+ console.print(f"[yellow]No recommendation: {escape(str(exc))}[/yellow]")
320
+ raise typer.Exit(code=1) from exc
321
+ except httpx.HTTPError as exc:
322
+ console.print(f"[red]Marketplace request failed: {escape(repr(exc))}[/red]")
323
+ raise typer.Exit(code=2) from exc
324
+ _render(result, top)
325
+ if save_path or not no_save:
326
+ path = save_report(result, query, top, save_path)
327
+ console.print(f"[green]Report saved to {escape(str(path))}[/green]")
328
+
329
+
330
+ def _parse_sources(raw: str) -> list[str]:
331
+ """Parse the ``--sources`` CSV into a validated ordered marketplace list."""
332
+ names = [s.strip().lower() for s in raw.split(",") if s.strip()]
333
+ invalid = [n for n in names if n not in _ALL_SOURCES]
334
+ if invalid:
335
+ raise typer.BadParameter(f"unknown source(s): {', '.join(invalid)}")
336
+ return names or list(_ALL_SOURCES)
337
+
338
+
339
+ _BUDGET_PROMPT = "Бюджет в ₸ (Enter — без лимита)"
340
+
341
+
342
+ def _resolve_budget(budget: int | None, parsed_budget: int | None) -> int | None:
343
+ """Fill in a missing budget interactively, TTY only.
344
+
345
+ ``--budget`` wins outright; a budget already found in the query is left
346
+ alone (``recommend`` keeps it, no override needed); otherwise ask — but
347
+ only on a real terminal, so scripts/CI never block on stdin.
348
+ """
349
+ if budget is not None or parsed_budget is not None:
350
+ return budget
351
+ if not sys.stdin.isatty():
352
+ return None
353
+ answer = typer.prompt(_BUDGET_PROMPT, default="", show_default=False).strip()
354
+ digits = answer.replace(" ", "")
355
+ return int(digits) if digits.isdigit() else None
356
+
357
+
358
+ async def recommend_pipeline(
359
+ query: str,
360
+ city: str | None,
361
+ budget: int | None,
362
+ use_llm: bool,
363
+ trusted_only: bool = False,
364
+ sources: list[str] | None = None,
365
+ with_review_summary: bool = True,
366
+ ) -> RecommendResult:
367
+ """Parse once, prompt for a missing budget, then run the recommend pipeline."""
368
+ parsed = await run_parse_request(query, use_llm)
369
+ budget = _resolve_budget(budget, parsed.budget_max)
370
+ return await run_recommend(
371
+ query,
372
+ city=city,
373
+ budget=budget,
374
+ use_llm=use_llm,
375
+ trusted_only=trusted_only,
376
+ sources=sources,
377
+ parsed=parsed,
378
+ with_review_summary=with_review_summary,
379
+ )
380
+
381
+
382
+ def _render_knowledge_summary(
383
+ category: str,
384
+ brand: BrandBook,
385
+ model: ModelBook,
386
+ category_type: str = "spec_driven",
387
+ ingredient: IngredientBook | None = None,
388
+ ) -> None:
389
+ """Print a tiers-and-models summary table for a category's knowledge."""
390
+ tiers = {"A": [], "B": [], "C": []}
391
+ for name, tier in brand.tiers.items():
392
+ tiers.setdefault(tier, []).append(name)
393
+ table = Table(title=f"Brand tiers — {category}", show_header=True, header_style="bold")
394
+ table.add_column("Tier")
395
+ table.add_column("N", justify="right")
396
+ table.add_column("Brands")
397
+ for tier in ("A", "B", "C"):
398
+ brands = tiers.get(tier, [])
399
+ table.add_row(tier, str(len(brands)), escape(", ".join(brands)) or "-")
400
+ console.print(table)
401
+ console.print(
402
+ f"[bold]Type:[/bold] {category_type} "
403
+ f"[bold]Models:[/bold] {len(model.entries)} "
404
+ f"[bold]Brands:[/bold] {len(brand.tiers)} "
405
+ f"[bold]Aliases:[/bold] {len(brand.aliases)}"
406
+ )
407
+ _render_ingredient_summary(ingredient)
408
+
409
+
410
+ def _render_ingredient_summary(ingredient: IngredientBook | None) -> None:
411
+ """Print the active-substance table for composition-driven categories."""
412
+ if ingredient is None or not ingredient.substances:
413
+ return
414
+ table = Table(title="Active substances", show_header=True, header_style="bold")
415
+ table.add_column("Substance")
416
+ table.add_column("Concentrations")
417
+ table.add_column("Equivalents")
418
+ for sub in ingredient.substances:
419
+ equivalents = ", ".join(e.name for e in sub.equivalents) or "-"
420
+ table.add_row(sub.canonical, ", ".join(sub.typical_concentrations) or "-", equivalents)
421
+ console.print(table)
422
+
423
+
424
+ @app.command("learn-category")
425
+ def learn_category(
426
+ category: str = typer.Argument(..., help='Product category, e.g. "робот-пылесос".'),
427
+ web: bool = typer.Option(
428
+ True,
429
+ "--web/--no-web",
430
+ help="Ground the generation in web search (claude CLI WebSearch).",
431
+ ),
432
+ ) -> None:
433
+ """Generate a BrandBook + ModelBook for a category via the LLM and save it."""
434
+ try:
435
+ brand, model, category_type, ingredient = asyncio.run(
436
+ _run_learn_category(category, web_search=web)
437
+ )
438
+ except Exception as exc: # noqa: BLE001 - surface a clean CLI error
439
+ console.print(f"[red]Knowledge generation failed: {escape(str(exc))}[/red]")
440
+ raise typer.Exit(code=1) from exc
441
+ generated_by = f"{GENERATE_KNOWLEDGE_MODEL} + web search" if web else GENERATE_KNOWLEDGE_MODEL
442
+ console.print(
443
+ f"[green]Saved knowledge for {escape(repr(category))} "
444
+ f"(generated by {generated_by}).[/green]"
445
+ )
446
+ _render_knowledge_summary(category, brand, model, category_type, ingredient)
447
+
448
+
449
+ async def _run_learn_category(
450
+ category: str, *, web_search: bool = True
451
+ ) -> tuple[BrandBook, ModelBook, str, IngredientBook]:
452
+ brand, model, category_type, ingredient = await generate_category_knowledge(
453
+ category, web_search=web_search
454
+ )
455
+ await save_knowledge(
456
+ category,
457
+ brand,
458
+ model,
459
+ GENERATE_KNOWLEDGE_MODEL,
460
+ category_type=category_type,
461
+ ingredient_book=ingredient,
462
+ )
463
+ return brand, model, category_type, ingredient
464
+
465
+
466
+ @app.command("learn")
467
+ def learn(
468
+ file: str = typer.Argument(..., help="Markdown research notes file."),
469
+ category: str = typer.Option(..., "--category", help="Category the notes describe."),
470
+ ) -> None:
471
+ """Extract books from your own notes and MERGE them into the stored knowledge."""
472
+ try:
473
+ brand, model, category_type, ingredient = asyncio.run(_run_learn_file(Path(file), category))
474
+ except Exception as exc: # noqa: BLE001 - surface a clean CLI error
475
+ console.print(f"[red]Learning from file failed: {escape(str(exc))}[/red]")
476
+ raise typer.Exit(code=1) from exc
477
+ console.print(
478
+ f"[green]Merged notes from {escape(file)} into knowledge "
479
+ f"for {escape(repr(category))}.[/green]"
480
+ )
481
+ _render_knowledge_summary(category, brand, model, category_type, ingredient)
482
+
483
+
484
+ async def _run_learn_file(
485
+ file: Path, category: str
486
+ ) -> tuple[BrandBook, ModelBook, str, IngredientBook]:
487
+ notes = file.read_text(encoding="utf-8")
488
+ new_brand, new_model, new_type, new_ingredient = await extract_category_knowledge(
489
+ category, notes
490
+ )
491
+ stored_brand, stored_model = await get_stored_books(category)
492
+ stored_type, stored_ingredient = await resolve_composition(category, use_llm=False)
493
+ # User's file wins on conflicts → it is the overlay on top of the stored base.
494
+ merged_brand = merge_brand_books(stored_brand or BrandBook(), new_brand)
495
+ merged_model = merge_model_books(stored_model or ModelBook(), new_model)
496
+ merged_ingredient = merge_ingredient_books(
497
+ stored_ingredient or IngredientBook(), new_ingredient
498
+ )
499
+ # An extraction that found no substances must not downgrade a stored
500
+ # composition-driven category back to spec-driven.
501
+ category_type = new_type if new_type != SPEC_DRIVEN else stored_type
502
+ await save_knowledge(
503
+ category,
504
+ merged_brand,
505
+ merged_model,
506
+ EXTRACT_KNOWLEDGE_MODEL,
507
+ category_type=category_type,
508
+ ingredient_book=merged_ingredient,
509
+ )
510
+ return merged_brand, merged_model, category_type, merged_ingredient
511
+
512
+
513
+ def _render_refresh(result: CategoryRefresh) -> None:
514
+ """One compact line per category + a small table of the biggest drops."""
515
+ console.print(
516
+ f"[bold]{escape(result.category)}[/bold]: {result.product_count} products, "
517
+ f"{result.changed} price change(s)"
518
+ )
519
+ if not result.drops:
520
+ return
521
+ table = Table(show_header=True, header_style="bold")
522
+ table.add_column("Product")
523
+ table.add_column("Was", justify="right")
524
+ table.add_column("Now", justify="right")
525
+ table.add_column("Drop", justify="right")
526
+ for change in result.drops:
527
+ table.add_row(
528
+ escape(change.title[:50]),
529
+ f"{change.old_price:,} ₸",
530
+ f"{change.new_price:,} ₸",
531
+ f"[green]{change.delta:,} ₸[/green]",
532
+ )
533
+ console.print(table)
534
+
535
+
536
+ @app.command()
537
+ def refresh() -> None:
538
+ """Re-scan every tracked category and record fresh price snapshots."""
539
+ results = asyncio.run(run_refresh_tracked())
540
+ if not results:
541
+ console.print(
542
+ "[yellow]No tracked categories yet — run `recommend` first "
543
+ "(or Postgres is unavailable).[/yellow]"
544
+ )
545
+ return
546
+ for result in results:
547
+ _render_refresh(result)
548
+
549
+
550
+ def _render_history(entry: ProductHistory) -> None:
551
+ """Snapshot table (date, min price, offers) for one matched product."""
552
+ title = f"{escape(entry.title)} [dim]\\[{escape(entry.source)}][/dim]"
553
+ table = Table(title=title, show_header=True, header_style="bold")
554
+ table.add_column("Date")
555
+ table.add_column("Min price", justify="right")
556
+ table.add_column("Offers", justify="right")
557
+ for snap in entry.snapshots:
558
+ table.add_row(snap.captured_at, f"{snap.min_price:,} ₸", str(snap.offer_count))
559
+ console.print(table)
560
+
561
+
562
+ @app.command()
563
+ def history(
564
+ query: str = typer.Argument(..., help="Product name or URL substring to look up."),
565
+ ) -> None:
566
+ """Show the recorded price-snapshot history for a matching product."""
567
+ entries = asyncio.run(run_product_history(query))
568
+ if not entries:
569
+ console.print(
570
+ f"[yellow]No history for {escape(repr(query))} "
571
+ "(no match, or Postgres unavailable).[/yellow]"
572
+ )
573
+ return
574
+ for entry in entries:
575
+ _render_history(entry)
576
+
577
+
578
+ _OZON_SETUP_PROBE_QUERY = "смартфон"
579
+
580
+
581
+ @app.command("ozon-setup")
582
+ def ozon_setup() -> None:
583
+ """Open a visible browser, pass Ozon's anti-bot check once, and save the session."""
584
+ path = ozon_state_path()
585
+ browser = OzonBrowser(base_url=get_settings().ozon_base_url, state_path=path)
586
+ probe = browser.composer_url(search_path(_OZON_SETUP_PROBE_QUERY))
587
+ console.print("Откроется окно браузера. Дождитесь результатов поиска Ozon…")
588
+ if not asyncio.run(browser.bootstrap(probe)):
589
+ console.print("[red]Не удалось получить сессию Ozon за отведённое время.[/red]")
590
+ raise typer.Exit(code=1)
591
+ console.print(f"[green]Сессия Ozon сохранена: {escape(str(path))}[/green]")
592
+
593
+
594
+ _DB_TIMEOUT_S = 3
595
+
596
+
597
+ async def _db_reachable(url: str) -> bool:
598
+ from sqlalchemy import text
599
+ from sqlalchemy.ext.asyncio import create_async_engine
600
+
601
+ engine = create_async_engine(url, connect_args={"timeout": _DB_TIMEOUT_S})
602
+ try:
603
+ async with engine.connect() as conn:
604
+ await conn.execute(text("select 1"))
605
+ return True
606
+ except Exception:
607
+ return False
608
+ finally:
609
+ await engine.dispose()
610
+
611
+
612
+ def _chromium_installed() -> bool:
613
+ from playwright.sync_api import sync_playwright
614
+
615
+ with sync_playwright() as p:
616
+ return Path(p.chromium.executable_path).exists()
617
+
618
+
619
+ def doctor_checks() -> list[tuple[str, bool, str]]:
620
+ """(name, ok, hint) for every optional piece the pipeline can use."""
621
+ settings = get_settings()
622
+ return [
623
+ (
624
+ "Postgres",
625
+ asyncio.run(_db_reachable(settings.database_url)),
626
+ "history and preferences off; in a clone run `make up && uv run alembic upgrade head`",
627
+ ),
628
+ (
629
+ "claude CLI",
630
+ shutil.which("claude") is not None,
631
+ "LLM parsing and review summaries off; install Claude Code or use --no-llm",
632
+ ),
633
+ (
634
+ "Playwright Chromium",
635
+ _chromium_installed(),
636
+ f'Ozon off; run `"{sys.executable}" -m playwright install chromium`',
637
+ ),
638
+ (
639
+ "Ozon session",
640
+ ozon_state_path().exists(),
641
+ "Ozon may hit the anti-bot page; run `buysmarter ozon-setup`",
642
+ ),
643
+ ]
644
+
645
+
646
+ @app.command()
647
+ def doctor() -> None:
648
+ """Check what is set up; every missing piece only disables part of the pipeline."""
649
+ for name, ok, hint in doctor_checks():
650
+ mark = "[green]ok[/green]" if ok else f"[yellow]missing[/yellow] {escape(hint)}"
651
+ console.print(f"{name:<20} {mark}")
652
+
653
+
654
+ if __name__ == "__main__":
655
+ app()