|
29 | 29 | ZoneInfo = None # type: ignore |
30 | 30 |
|
31 | 31 | from tracklater import settings |
32 | | -from tracklater.models import Entry |
| 32 | +from tracklater.database import db |
| 33 | +from tracklater.models import Entry, EntrySuggestion |
33 | 34 | from tracklater.ai_local import ( |
34 | 35 | MODULE_NAME, |
35 | 36 | _allowed_local_projects, |
@@ -245,8 +246,14 @@ def build_day_prompt( |
245 | 246 | bridged billable hours with the weekday-daytime / evening / weekend rules, and |
246 | 247 | blocks snapped to :00/:30. |
247 | 248 |
|
| 249 | +For EACH entry also provide ranked alternatives the user might pick instead when |
| 250 | +editing: `project_options` (2-4 plausible `group:Project` for this block, BEST |
| 251 | +first — the first MUST equal `project`) and `title_options` (2-4 plausible titles, |
| 252 | +BEST first — the first MUST equal `title`). These are hints for a dropdown. |
| 253 | +
|
248 | 254 | Output ONLY a JSON array, no prose, no code fence. Each item: |
249 | | -{{"date":"{day}","start":"HH:MM","end":"HH:MM","project":"group:Project","title":"..."}} |
| 255 | +{{"date":"{day}","start":"HH:MM","end":"HH:MM","project":"group:Project","title":"...", |
| 256 | + "project_options":["group:Project", ...],"title_options":["...", ...]}} |
250 | 257 | Times are LOCAL 24h. Entries must not overlap. If the day is unbillable (leave / |
251 | 258 | no work), output an empty array [].""" |
252 | 259 |
|
@@ -313,16 +320,50 @@ def parse_entries( |
313 | 320 | e += timedelta(days=1) # crossed midnight |
314 | 321 | # Model reasons in local time; persist UTC to match every other module. |
315 | 322 | s, e = _to_utc(s), _to_utc(e) |
| 323 | + title = str(item.get('title') or 'Work')[:255] |
| 324 | + # Ranked editor hints: keep only allowed projects, ensure the chosen one |
| 325 | + # leads, dedupe, cap at 4. Fall back to the single chosen value. |
| 326 | + popts = [p for p in (item.get('project_options') or []) if p in allowed_projects] |
| 327 | + popts = [project] + [p for p in popts if p != project] |
| 328 | + topts = [str(t)[:255] for t in (item.get('title_options') or []) if t] |
| 329 | + topts = [title] + [t for t in topts if t != title] |
316 | 330 | entries.append({ |
317 | 331 | 'start_time': s, |
318 | 332 | 'end_time': e, |
319 | | - 'title': str(item.get('title') or 'Work')[:255], |
| 333 | + 'title': title, |
320 | 334 | 'project': project, |
| 335 | + 'project_options': list(dict.fromkeys(popts))[:4], |
| 336 | + 'title_options': list(dict.fromkeys(topts))[:4], |
321 | 337 | }) |
322 | 338 | entries.sort(key=lambda x: x['start_time']) |
323 | 339 | return entries |
324 | 340 |
|
325 | 341 |
|
| 342 | +def _write_suggestions( |
| 343 | + entries_data: List[Dict[str, Any]], |
| 344 | + win_start: Optional[datetime] = None, |
| 345 | + win_end: Optional[datetime] = None, |
| 346 | + replace: bool = True, |
| 347 | +) -> None: |
| 348 | + """Persist per-entry project/title hints (EntrySuggestion rows). When replace |
| 349 | + and a window is given, clear existing hints starting in [win_start, win_end] |
| 350 | + first so a re-fill doesn't accumulate stale rows.""" |
| 351 | + if replace and win_start is not None and win_end is not None: |
| 352 | + EntrySuggestion.query.filter( |
| 353 | + EntrySuggestion.start_time >= win_start, |
| 354 | + EntrySuggestion.start_time <= win_end, |
| 355 | + ).delete() |
| 356 | + for item in entries_data: |
| 357 | + db.session.add(EntrySuggestion( |
| 358 | + start_time=item['start_time'], |
| 359 | + end_time=item['end_time'], |
| 360 | + date_group=item['start_time'].strftime('%Y-%m-%d'), |
| 361 | + projects=item.get('project_options') or [item['project']], |
| 362 | + titles=item.get('title_options') or [item['title']], |
| 363 | + )) |
| 364 | + db.session.commit() |
| 365 | + |
| 366 | + |
326 | 367 | def populate_local_entries_ai( |
327 | 368 | start_date: datetime, |
328 | 369 | end_date: datetime, |
@@ -372,6 +413,200 @@ def populate_local_entries_ai( |
372 | 413 | ) |
373 | 414 | if errors: |
374 | 415 | logger.warning("Some days failed and were skipped: %s", "; ".join(errors)) |
375 | | - return persist_local_entries( |
| 416 | + created = persist_local_entries( |
376 | 417 | entries_data, start_date, end_date, replace_existing=replace_existing, |
377 | 418 | ) |
| 419 | + _write_suggestions(entries_data, start_date, end_date, replace=replace_existing) |
| 420 | + return created |
| 421 | + |
| 422 | + |
| 423 | +# --------------------------------------------------------------------------- # |
| 424 | +# Feature 2: streaming week-fill (per-day, cancellable, keeps finished days) |
| 425 | +# --------------------------------------------------------------------------- # |
| 426 | +def _local_day_utc_bounds(day: str) -> (datetime, datetime): |
| 427 | + """UTC [start, end) covering one LOCAL calendar day, for scoping the |
| 428 | + per-day draft-delete in persist_local_entries to just that day.""" |
| 429 | + local_start = datetime.strptime(day, '%Y-%m-%d') |
| 430 | + local_end = local_start + timedelta(days=1) |
| 431 | + return _to_utc(local_start), _to_utc(local_end) |
| 432 | + |
| 433 | + |
| 434 | +def stream_populate_local_entries_ai( |
| 435 | + start_date: datetime, |
| 436 | + end_date: datetime, |
| 437 | + replace_existing: bool = True, |
| 438 | +): |
| 439 | + """Generator variant of populate_local_entries_ai for streaming. |
| 440 | +
|
| 441 | + Yields progress dicts as each day completes and persists that day's entries |
| 442 | + immediately (per-day), so a cancellation (the consumer stops iterating) keeps |
| 443 | + the days already finished and leaves untouched days alone. Each yielded dict: |
| 444 | + {'type': 'progress', 'day', 'done', 'total', 'count'} |
| 445 | + {'type': 'day_error', 'day', 'error'} |
| 446 | + {'type': 'done', 'count', 'errors'} |
| 447 | + {'type': 'error', 'error'} (fatal, before any day ran) |
| 448 | + """ |
| 449 | + if MODULE_NAME not in settings.ENABLED_MODULES: |
| 450 | + yield {'type': 'error', 'error': 'local module is not enabled'} |
| 451 | + return |
| 452 | + if not _has_source_data(start_date, end_date): |
| 453 | + yield {'type': 'error', 'error': |
| 454 | + 'No source timemodule entries in this range. ' |
| 455 | + 'Fetch activitywatch/git/etc. first.'} |
| 456 | + return |
| 457 | + allowed_projects = _allowed_local_projects(start_date, end_date) |
| 458 | + if not allowed_projects: |
| 459 | + yield {'type': 'error', 'error': 'No LOCAL projects configured'} |
| 460 | + return |
| 461 | + digests = gather_signal(start_date, end_date) |
| 462 | + if not digests: |
| 463 | + yield {'type': 'error', 'error': |
| 464 | + 'No git/ActivityWatch signal in this range to reconstruct from.'} |
| 465 | + return |
| 466 | + |
| 467 | + days = sorted(digests) |
| 468 | + total = len(days) |
| 469 | + prior_titles: List[str] = [] |
| 470 | + errors: List[str] = [] |
| 471 | + total_count = 0 |
| 472 | + for idx, day in enumerate(days): |
| 473 | + prompt = build_day_prompt(day, digests[day], allowed_projects, prior_titles) |
| 474 | + logger.info("Streaming claude (%s) for local entries %s", _model(), day) |
| 475 | + try: |
| 476 | + raw = run_claude(prompt) |
| 477 | + day_entries = parse_entries(raw, allowed_projects) |
| 478 | + except ValueError as exc: |
| 479 | + logger.warning("claude failed for %s: %s", day, exc) |
| 480 | + errors.append(f"{day}: {exc}") |
| 481 | + yield {'type': 'day_error', 'day': day, 'error': str(exc)} |
| 482 | + continue |
| 483 | + # Persist THIS day now, scoping the draft-delete to the local day so a |
| 484 | + # later cancel can't roll it back and untouched days keep their entries. |
| 485 | + d_start, d_end = _local_day_utc_bounds(day) |
| 486 | + try: |
| 487 | + if day_entries: |
| 488 | + persist_local_entries( |
| 489 | + day_entries, d_start, d_end, replace_existing=replace_existing, |
| 490 | + ) |
| 491 | + _write_suggestions(day_entries, d_start, d_end, replace=replace_existing) |
| 492 | + total_count += len(day_entries) |
| 493 | + except ValueError as exc: |
| 494 | + logger.warning("persist failed for %s: %s", day, exc) |
| 495 | + errors.append(f"{day}: {exc}") |
| 496 | + yield {'type': 'day_error', 'day': day, 'error': str(exc)} |
| 497 | + continue |
| 498 | + for e in day_entries: |
| 499 | + if e['title'] not in prior_titles: |
| 500 | + prior_titles.append(e['title']) |
| 501 | + yield {'type': 'progress', 'day': day, 'done': idx + 1, |
| 502 | + 'total': total, 'count': len(day_entries)} |
| 503 | + yield {'type': 'done', 'count': total_count, 'errors': errors} |
| 504 | + |
| 505 | + |
| 506 | +# --------------------------------------------------------------------------- # |
| 507 | +# Feature 1: single entry from a double-click (grow a block from the click seed) |
| 508 | +# --------------------------------------------------------------------------- # |
| 509 | +def build_click_prompt( |
| 510 | + day: str, day_signal: str, allowed_projects: set, prior_titles: List[str], |
| 511 | + click_local: datetime, gap_lo_local: Optional[datetime], |
| 512 | + gap_hi_local: Optional[datetime], |
| 513 | +) -> str: |
| 514 | + """Prompt Claude for ONE entry grown outward from the click point.""" |
| 515 | + guidebook = _read_guidebook() |
| 516 | + projects = '\n'.join(f' - {p}' for p in sorted(allowed_projects)) |
| 517 | + carry = ( |
| 518 | + '\nEpic titles already in use this week (reuse the matching one to carry an ' |
| 519 | + 'epic forward, per the guidebook):\n ' + '\n '.join(prior_titles) |
| 520 | + if prior_titles else '' |
| 521 | + ) |
| 522 | + bounds = '' |
| 523 | + if gap_lo_local is not None: |
| 524 | + bounds += f"\n- The entry MUST NOT start before {gap_lo_local.strftime('%H:%M')} (previous entry)." |
| 525 | + if gap_hi_local is not None: |
| 526 | + bounds += f"\n- The entry MUST NOT end after {gap_hi_local.strftime('%H:%M')} (next entry)." |
| 527 | + return f"""You reconstruct ONE manual billing time entry from git + ActivityWatch data. |
| 528 | +Follow the guidebook below EXACTLY for client/project selection, titles and the |
| 529 | +billable-hours model. |
| 530 | +
|
| 531 | +================ GUIDEBOOK ================ |
| 532 | +{guidebook} |
| 533 | +================ END GUIDEBOOK ================ |
| 534 | +
|
| 535 | +Allowed projects (use these exact `group:Project` strings, nothing else): |
| 536 | +{projects} |
| 537 | +{carry} |
| 538 | +
|
| 539 | +Source signal for {day} (LOCAL time already): |
| 540 | +{day_signal} |
| 541 | +
|
| 542 | +TASK: the user double-clicked the timeline at {click_local.strftime('%H:%M')} to create |
| 543 | +a SINGLE entry there. Build exactly ONE entry: |
| 544 | +1. SEED: within ±30 min of {click_local.strftime('%H:%M')}, find the single dominant |
| 545 | + project/component (its commits + ActivityWatch activity). That is this entry's |
| 546 | + project. Fold sub-projects into their parent per the guidebook. |
| 547 | +2. GROW: extend the block earlier and later from the click. Keep extending each side |
| 548 | + while the SAME project/component's commits/activity continue. STOP a side as soon |
| 549 | + as the focus clearly switches to a different project/component, or at a real AFK |
| 550 | + gap.{bounds} |
| 551 | +3. Snap start/end to :00/:30. Title per the guidebook (branch slug first). |
| 552 | +4. If there is NO meaningful signal within ±30 min of the click, output an empty |
| 553 | + array [] (the UI will create a blank entry to fill in by hand). |
| 554 | +
|
| 555 | +Also provide ranked alternatives for the editor dropdown: `project_options` (2-4 |
| 556 | +plausible `group:Project`, BEST first — first MUST equal `project`) and |
| 557 | +`title_options` (2-4 plausible titles, BEST first — first MUST equal `title`). |
| 558 | +
|
| 559 | +Output ONLY a JSON array with AT MOST ONE item, no prose, no code fence: |
| 560 | +[{{"date":"{day}","start":"HH:MM","end":"HH:MM","project":"group:Project","title":"...", |
| 561 | + "project_options":["group:Project", ...],"title_options":["...", ...]}}] |
| 562 | +Times are LOCAL 24h.""" |
| 563 | + |
| 564 | + |
| 565 | +def populate_entry_at( |
| 566 | + click: datetime, |
| 567 | + prev_end: Optional[datetime] = None, |
| 568 | + next_start: Optional[datetime] = None, |
| 569 | +) -> List[Entry]: |
| 570 | + """Create a single local entry grown from a double-click at `click` (naive UTC). |
| 571 | +
|
| 572 | + `prev_end`/`next_start` (naive UTC) bound the free gap so the result can't |
| 573 | + overlap neighbours. Returns the created entries (0 or 1); an empty list means |
| 574 | + 'no signal near the click' — the caller drops a blank manual entry instead. |
| 575 | + """ |
| 576 | + if MODULE_NAME not in settings.ENABLED_MODULES: |
| 577 | + raise ValueError("local module is not enabled") |
| 578 | + click_local = _to_local(click) |
| 579 | + day = click_local.strftime('%Y-%m-%d') |
| 580 | + day_start = datetime.strptime(day, '%Y-%m-%d') |
| 581 | + day_end = day_start + timedelta(days=1) |
| 582 | + # _to_utc the local day bounds to gather that whole local day's signal. |
| 583 | + digests = gather_signal(_to_utc(day_start), _to_utc(day_end) - timedelta(seconds=1)) |
| 584 | + if day not in digests: |
| 585 | + return [] # no signal that day at all -> blank manual entry |
| 586 | + allowed_projects = _allowed_local_projects(_to_utc(day_start), _to_utc(day_end)) |
| 587 | + if not allowed_projects: |
| 588 | + raise ValueError("No LOCAL projects configured") |
| 589 | + prompt = build_click_prompt( |
| 590 | + day, digests[day], allowed_projects, [], click_local, |
| 591 | + _to_local(prev_end) if prev_end else None, |
| 592 | + _to_local(next_start) if next_start else None, |
| 593 | + ) |
| 594 | + logger.info("Calling claude (%s) for click entry at %s", _model(), click_local) |
| 595 | + raw = run_claude(prompt) |
| 596 | + entries_data = parse_entries(raw, allowed_projects) |
| 597 | + if not entries_data: |
| 598 | + return [] |
| 599 | + entries_data = entries_data[:1] # exactly one entry from a click |
| 600 | + # Hard clamp to the free gap so a stray model end can never overlap neighbours. |
| 601 | + e = entries_data[0] |
| 602 | + if prev_end is not None and e['start_time'] < prev_end: |
| 603 | + e['start_time'] = prev_end |
| 604 | + if next_start is not None and e['end_time'] > next_start: |
| 605 | + e['end_time'] = next_start |
| 606 | + if e['end_time'] <= e['start_time']: |
| 607 | + return [] |
| 608 | + created = persist_local_entries( |
| 609 | + entries_data, e['start_time'], e['end_time'], replace_existing=False, |
| 610 | + ) |
| 611 | + _write_suggestions(entries_data, e['start_time'], e['end_time'], replace=True) |
| 612 | + return created |
0 commit comments