diff --git a/tests/test_surfaces_profile.py b/tests/test_surfaces_profile.py index e71e7debc..754ab332b 100644 --- a/tests/test_surfaces_profile.py +++ b/tests/test_surfaces_profile.py @@ -289,7 +289,7 @@ def test_cadence_multi_day(tmp_path, monkeypatch): cadence = profile_surface.cadence("Ravi") assert cadence is not None - assert cadence.interactions_90d == 3 + assert cadence.recent_interactions_count_30d == 3 assert cadence.last_seen == "20260418" assert cadence.avg_interval_days == 4.0 assert cadence.gone_quiet_since is None @@ -337,11 +337,59 @@ def test_cadence_gone_quiet_threshold(tmp_path, monkeypatch): boundary = profile_surface.cadence("Boundary Person") assert quiet is not None - assert quiet.gone_quiet_since == "20260406" + assert quiet.gone_quiet_since == 14 assert boundary is not None assert boundary.gone_quiet_since is None +def test_cadence_gone_quiet_returns_int_days(tmp_path, monkeypatch): + from think.surfaces import profile as profile_surface + + _configure_env(tmp_path, monkeypatch) + _minimal_facet_tree( + tmp_path, + journal_entities=( + {"id": "far_person", "name": "Far Person", "type": "Person"}, + {"id": "equal_person", "name": "Equal Person", "type": "Person"}, + {"id": "recent_person", "name": "Recent Person", "type": "Person"}, + ), + ) + for entity_id, description in ( + ("far_person", "Far"), + ("equal_person", "Equal"), + ("recent_person", "Recent"), + ): + _write_facet_relationship(tmp_path, "work", entity_id, description=description) + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") + + for entity_id, days in ( + ("far_person", ("20260330", "20260404")), + ("equal_person", ("20260405", "20260410")), + ("recent_person", ("20260406", "20260411")), + ): + for day in days: + _append_activity( + "work", + day, + _activity_record( + day, + [_participant(entity_id, name=entity_id.replace("_", " ").title())], + record_id=f"{entity_id}_{day}", + ), + ) + + far = profile_surface.cadence("Far Person") + equal = profile_surface.cadence("Equal Person") + recent = profile_surface.cadence("Recent Person") + + assert far is not None + assert far.gone_quiet_since == 16 + assert equal is not None + assert equal.gone_quiet_since is None + assert recent is not None + assert recent.gone_quiet_since is None + + def test_cadence_distinct_days_vs_record_count(tmp_path, monkeypatch): from think.surfaces import profile as profile_surface @@ -422,7 +470,7 @@ def test_facets_filter_narrows_display_not_cadence(tmp_path, monkeypatch): assert profile is not None assert profile.facets == () assert profile.description is None - assert profile.cadence.interactions_90d == 1 + assert profile.cadence.recent_interactions_count_30d == 1 def test_include_mentions_toggle(tmp_path, monkeypatch): @@ -602,12 +650,7 @@ def test_full_composes_ledger_decisions(tmp_path, monkeypatch): decisions=[_decision(owner="Ravi", owner_entity_id="ravi")], ) - expected = tuple( - ledger_surface.decisions( - involving="ravi", - since=profile_surface._day_minus(30), # noqa: SLF001 - ) - ) + expected = tuple(ledger_surface.decisions(involving="ravi")) profile = profile_surface.full("Ravi") assert profile is not None @@ -615,6 +658,32 @@ def test_full_composes_ledger_decisions(tmp_path, monkeypatch): assert len(profile.decisions_involving_them) >= 1 +def test_full_decisions_involving_them_includes_old(tmp_path, monkeypatch): + from think.surfaces import profile as profile_surface + + _configure_env(tmp_path, monkeypatch) + _minimal_facet_tree( + tmp_path, + journal_entities=({"id": "ravi", "name": "Ravi", "type": "Person"},), + ) + _write_facet_relationship(tmp_path, "work", "ravi", description="Customer") + monkeypatch.setattr(profile_surface, "_today_day", lambda: "20260420") + _write_story_activity( + "work", + "20260301", + "meeting_090000_300", + _utc_ms("20260301", 9), + decisions=[_decision(owner="Ravi", owner_entity_id="ravi")], + ) + + profile = profile_surface.full("Ravi") + + assert profile is not None + assert any( + decision.day == "20260301" for decision in profile.decisions_involving_them + ) + + def test_not_found_returns_none(tmp_path, monkeypatch): from think.surfaces import profile as profile_surface from think.tools.profile import app @@ -659,13 +728,28 @@ def test_brief_shape_and_counts(tmp_path, monkeypatch): brief = profile_surface.brief("Ravi") assert brief is not None + assert tuple(brief.__dataclass_fields__) == ( + "entity_id", + "name", + "type", + "description", + "last_seen", + "open_loop_count", + "decisions_count_30d", + ) + assert brief.entity_id == "ravi" + assert brief.name == "Ravi" + assert brief.type == "Person" + assert brief.description == "Customer" + assert brief.last_seen is None assert brief.open_loop_count == len( ledger_surface.list(state="open", counterparty="ravi") ) assert brief.decisions_count_30d == len( ledger_surface.decisions(involving="ravi", since="20260321") ) - assert brief.last_seen is None + assert not hasattr(brief, "is_self") + assert not hasattr(brief, "generated_at") def test_list_active_sort_dedup_window(tmp_path, monkeypatch): diff --git a/think/surfaces/profile.py b/think/surfaces/profile.py index 6d588f53c..d496c051a 100644 --- a/think/surfaces/profile.py +++ b/think/surfaces/profile.py @@ -40,6 +40,19 @@ class _ResolvedTarget: is_principal: bool facets_with_desc: dict[str, str] + def description_for(self, facets: tuple[str, ...] | None) -> str | None: + selected_facets = ( + tuple(self.facets_with_desc.keys()) + if facets is None + else tuple(facet for facet in facets if facet in self.facets_with_desc) + ) + descriptions = [ + self.facets_with_desc[facet] + for facet in selected_facets + if self.facets_with_desc[facet] + ] + return " | ".join(descriptions) if descriptions else None + def _today_day() -> str: return datetime.now(UTC).strftime("%Y%m%d") @@ -175,7 +188,9 @@ def _compute_cadence( distinct_days = sorted(set(interaction_days)) last_seen = distinct_days[-1] avg_interval_days: float | None = None - gone_quiet_since: str | None = None + gone_quiet_since: int | None = None + recent_since = _day_minus(30) + recent_count = sum(1 for day in interaction_days if day >= recent_since) if len(distinct_days) >= 2: first_ordinal = _day_to_ordinal(distinct_days[0]) @@ -183,14 +198,9 @@ def _compute_cadence( avg_interval_days = (last_ordinal - first_ordinal) / (len(distinct_days) - 1) quiet_gap_days = _day_to_ordinal(_today_day()) - last_ordinal if quiet_gap_days > avg_interval_days * 2: - gone_quiet_since = last_seen + gone_quiet_since = quiet_gap_days - cadence = Cadence( - interactions_90d=len(interaction_days), - last_seen=last_seen, - avg_interval_days=avg_interval_days, - gone_quiet_since=gone_quiet_since, - ) + cadence = Cadence(recent_count, last_seen, avg_interval_days, gone_quiet_since) return cadence, tuple(sorted(sources, key=_source_sort_key)) @@ -210,13 +220,6 @@ def full( selected_facets = tuple( facet for facet in facets if facet in target.facets_with_desc ) - - descriptions = [ - target.facets_with_desc[facet] - for facet in selected_facets - if target.facets_with_desc[facet] - ] - description = " | ".join(descriptions) if descriptions else None closed_since = _day_minus(30) return Profile( @@ -226,7 +229,7 @@ def full( aka=target.aka, is_self=target.is_principal, facets=selected_facets, - description=description, + description=target.description_for(None if facets is None else tuple(facets)), cadence=cadence, open_with_them=tuple(ledger.list(state="open", counterparty=target.entity_id)), closed_with_them_30d=tuple( @@ -236,11 +239,9 @@ def full( counterparty=target.entity_id, ) ), - decisions_involving_them=tuple( - ledger.decisions(involving=target.entity_id, since=closed_since) - ), + decisions_involving_them=tuple(ledger.decisions(involving=target.entity_id)), sources=sources, - generated_at=datetime.now(UTC).isoformat(), + generated_at=int(datetime.now(UTC).timestamp() * 1000), ) @@ -250,17 +251,17 @@ def brief(name: str) -> ProfileBrief | None: return None cadence, _ = _compute_cadence(target.entity_id, include_mentions=False) - closed_since = _day_minus(30) + decision_since = _day_minus(30) return ProfileBrief( entity_id=target.entity_id, name=target.name, - is_self=target.is_principal, + type=target.type, + description=target.description_for(None), + last_seen=cadence.last_seen, open_loop_count=len(ledger.list(state="open", counterparty=target.entity_id)), decisions_count_30d=len( - ledger.decisions(involving=target.entity_id, since=closed_since) + ledger.decisions(involving=target.entity_id, since=decision_since) ), - last_seen=cadence.last_seen, - generated_at=datetime.now(UTC).isoformat(), ) diff --git a/think/surfaces/types.py b/think/surfaces/types.py index c1e1a7ba7..c355239cc 100644 --- a/think/surfaces/types.py +++ b/think/surfaces/types.py @@ -45,21 +45,21 @@ class Decision: @dataclass(frozen=True) class Cadence: - interactions_90d: int + recent_interactions_count_30d: int last_seen: str | None avg_interval_days: float | None - gone_quiet_since: str | None + gone_quiet_since: int | None @dataclass(frozen=True) class ProfileBrief: entity_id: str name: str - is_self: bool + type: str + description: str | None + last_seen: str | None open_loop_count: int decisions_count_30d: int - last_seen: str | None - generated_at: str @dataclass(frozen=True) @@ -76,4 +76,4 @@ class Profile: closed_with_them_30d: tuple[LedgerItem, ...] decisions_involving_them: tuple[Decision, ...] sources: tuple[ActivitySourceRef, ...] - generated_at: str + generated_at: int diff --git a/think/tools/profile.py b/think/tools/profile.py index 7d7916473..5cefaa8ee 100644 --- a/think/tools/profile.py +++ b/think/tools/profile.py @@ -60,7 +60,10 @@ def _render_full(profile: Profile) -> None: typer.echo("") typer.echo("Cadence:") typer.echo(f" last_seen: {profile.cadence.last_seen}") - typer.echo(f" interactions_90d: {profile.cadence.interactions_90d}") + typer.echo( + " recent_interactions_count_30d: " + f"{profile.cadence.recent_interactions_count_30d}" + ) typer.echo(f" avg_interval_days: {profile.cadence.avg_interval_days}") typer.echo(f" gone_quiet_since: {profile.cadence.gone_quiet_since}") typer.echo("") @@ -114,15 +117,17 @@ def _render_full(profile: Profile) -> None: def _render_brief(profile_brief: profile_surface.ProfileBrief) -> None: typer.echo(f"entity_id: {profile_brief.entity_id}") typer.echo(f"name: {profile_brief.name}") - typer.echo(f"is_self: {profile_brief.is_self}") + typer.echo(f"type: {profile_brief.type}") + typer.echo(f"description: {profile_brief.description}") + typer.echo(f"last_seen: {profile_brief.last_seen}") typer.echo(f"open_loop_count: {profile_brief.open_loop_count}") typer.echo(f"decisions_count_30d: {profile_brief.decisions_count_30d}") - typer.echo(f"last_seen: {profile_brief.last_seen}") - typer.echo(f"generated_at: {profile_brief.generated_at}") def _render_cadence(cadence: profile_surface.Cadence) -> None: - typer.echo(f"interactions_90d: {cadence.interactions_90d}") + typer.echo( + f"recent_interactions_count_30d: {cadence.recent_interactions_count_30d}" + ) typer.echo(f"last_seen: {cadence.last_seen}") typer.echo(f"avg_interval_days: {cadence.avg_interval_days}") typer.echo(f"gone_quiet_since: {cadence.gone_quiet_since}")