kindred-keeper 0.1.0__tar.gz
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.
- kindred_keeper-0.1.0/LICENSE +21 -0
- kindred_keeper-0.1.0/MANIFEST.in +2 -0
- kindred_keeper-0.1.0/PKG-INFO +113 -0
- kindred_keeper-0.1.0/README.md +95 -0
- kindred_keeper-0.1.0/examples/notes.txt +3 -0
- kindred_keeper-0.1.0/kindred_keeper/__init__.py +3 -0
- kindred_keeper-0.1.0/kindred_keeper/engine.py +360 -0
- kindred_keeper-0.1.0/kindred_keeper/server.py +108 -0
- kindred_keeper-0.1.0/kindred_keeper/static/__init__.py +1 -0
- kindred_keeper-0.1.0/kindred_keeper/static/app.js +123 -0
- kindred_keeper-0.1.0/kindred_keeper/static/index.html +129 -0
- kindred_keeper-0.1.0/kindred_keeper/static/styles.css +285 -0
- kindred_keeper-0.1.0/kindred_keeper/storage.py +113 -0
- kindred_keeper-0.1.0/kindred_keeper.egg-info/PKG-INFO +113 -0
- kindred_keeper-0.1.0/kindred_keeper.egg-info/SOURCES.txt +19 -0
- kindred_keeper-0.1.0/kindred_keeper.egg-info/dependency_links.txt +1 -0
- kindred_keeper-0.1.0/kindred_keeper.egg-info/entry_points.txt +2 -0
- kindred_keeper-0.1.0/kindred_keeper.egg-info/top_level.txt +1 -0
- kindred_keeper-0.1.0/pyproject.toml +30 -0
- kindred_keeper-0.1.0/setup.cfg +4 -0
- kindred_keeper-0.1.0/tests/test_engine.py +64 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kindred Keeper maintainers
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kindred-keeper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A local-first relationship memory desk for thoughtful follow-ups.
|
|
5
|
+
Author: Kindred Keeper maintainers
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: relationships,personal-crm,local-first,planning
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Kindred Keeper
|
|
20
|
+
|
|
21
|
+
Kindred Keeper is a private, local-first relationship memory desk for thoughtful follow-ups. Add people you care about, jot down small details after calls or visits, and it builds a practical plan for check-ins, promises, birthdays, and meaningful moments.
|
|
22
|
+
|
|
23
|
+
Everything runs on your computer with the Python standard library. There is no account, upload, telemetry, or hosted database.
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- Local browser app backed by a JSON file on your machine.
|
|
30
|
+
- Contact profiles with relationship, cadence, interests, and last touchpoint.
|
|
31
|
+
- Note analysis that extracts interests, promises, dates, sentiment, and relationship signals.
|
|
32
|
+
- Lightweight local classifier trained on celebration, support, commitment, and interest examples.
|
|
33
|
+
- Planning workflow that ranks overdue follow-ups, open commitments, and upcoming dates.
|
|
34
|
+
- Suggested message drafts that stay specific to the person and the reason for reaching out.
|
|
35
|
+
- Copy-ready weekly relationship plan.
|
|
36
|
+
- Seeded demo workspace so the full workflow is visible on first launch.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
Requirements:
|
|
41
|
+
|
|
42
|
+
- Python 3.10 or newer
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/iwaheedsattar/kindred-keeper.git
|
|
46
|
+
cd kindred-keeper
|
|
47
|
+
python3 app.py
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Open [http://127.0.0.1:8768](http://127.0.0.1:8768).
|
|
51
|
+
|
|
52
|
+
To use a custom local data file:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python3 app.py --data ./my-kindred-data.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Example Usage
|
|
59
|
+
|
|
60
|
+
1. Launch the app with `python3 app.py`.
|
|
61
|
+
2. Review the seeded relationship plan.
|
|
62
|
+
3. Add a person you want to stay close to.
|
|
63
|
+
4. Save a note such as:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
Lina loves Korean food and ceramics. Promised to send the podcast link. Her recital is Jul 16.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
5. Review the follow-up plan and copy the weekly plan into your calendar or notes app.
|
|
70
|
+
|
|
71
|
+
## How It Works
|
|
72
|
+
|
|
73
|
+
Kindred Keeper uses a small local planning pipeline:
|
|
74
|
+
|
|
75
|
+
1. Notes are tokenized and scored with a compact multinomial classifier.
|
|
76
|
+
2. Extractors find promises, dates, and interest phrases.
|
|
77
|
+
3. Each contact receives warmth and attention scores based on cadence, open commitments, support signals, and recent notes.
|
|
78
|
+
4. The planner ranks next actions and writes a short message draft for each one.
|
|
79
|
+
|
|
80
|
+
The pipeline is intentionally small and transparent so personal relationship notes stay local and understandable.
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
python3 -m unittest discover -s tests
|
|
86
|
+
python3 app.py --no-open --data ./.kindred-keeper-data.json
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Package build:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
python3 -m build --no-isolation
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Project Layout
|
|
96
|
+
|
|
97
|
+
- `kindred_keeper/engine.py` contains note analysis, scoring, and planning.
|
|
98
|
+
- `kindred_keeper/storage.py` manages the local JSON workspace.
|
|
99
|
+
- `kindred_keeper/server.py` serves the local web app and JSON API.
|
|
100
|
+
- `static/` contains the browser UI.
|
|
101
|
+
- `tests/` covers extraction, summary scoring, and action planning.
|
|
102
|
+
|
|
103
|
+
## Roadmap
|
|
104
|
+
|
|
105
|
+
- Calendar export for planned check-ins.
|
|
106
|
+
- Import from a simple contacts CSV.
|
|
107
|
+
- Reminder notifications in a desktop wrapper.
|
|
108
|
+
- Optional encrypted data file.
|
|
109
|
+
- Better recurring-date handling for birthdays and anniversaries.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Kindred Keeper
|
|
2
|
+
|
|
3
|
+
Kindred Keeper is a private, local-first relationship memory desk for thoughtful follow-ups. Add people you care about, jot down small details after calls or visits, and it builds a practical plan for check-ins, promises, birthdays, and meaningful moments.
|
|
4
|
+
|
|
5
|
+
Everything runs on your computer with the Python standard library. There is no account, upload, telemetry, or hosted database.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Local browser app backed by a JSON file on your machine.
|
|
12
|
+
- Contact profiles with relationship, cadence, interests, and last touchpoint.
|
|
13
|
+
- Note analysis that extracts interests, promises, dates, sentiment, and relationship signals.
|
|
14
|
+
- Lightweight local classifier trained on celebration, support, commitment, and interest examples.
|
|
15
|
+
- Planning workflow that ranks overdue follow-ups, open commitments, and upcoming dates.
|
|
16
|
+
- Suggested message drafts that stay specific to the person and the reason for reaching out.
|
|
17
|
+
- Copy-ready weekly relationship plan.
|
|
18
|
+
- Seeded demo workspace so the full workflow is visible on first launch.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Requirements:
|
|
23
|
+
|
|
24
|
+
- Python 3.10 or newer
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
git clone https://github.com/iwaheedsattar/kindred-keeper.git
|
|
28
|
+
cd kindred-keeper
|
|
29
|
+
python3 app.py
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Open [http://127.0.0.1:8768](http://127.0.0.1:8768).
|
|
33
|
+
|
|
34
|
+
To use a custom local data file:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
python3 app.py --data ./my-kindred-data.json
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Example Usage
|
|
41
|
+
|
|
42
|
+
1. Launch the app with `python3 app.py`.
|
|
43
|
+
2. Review the seeded relationship plan.
|
|
44
|
+
3. Add a person you want to stay close to.
|
|
45
|
+
4. Save a note such as:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
Lina loves Korean food and ceramics. Promised to send the podcast link. Her recital is Jul 16.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
5. Review the follow-up plan and copy the weekly plan into your calendar or notes app.
|
|
52
|
+
|
|
53
|
+
## How It Works
|
|
54
|
+
|
|
55
|
+
Kindred Keeper uses a small local planning pipeline:
|
|
56
|
+
|
|
57
|
+
1. Notes are tokenized and scored with a compact multinomial classifier.
|
|
58
|
+
2. Extractors find promises, dates, and interest phrases.
|
|
59
|
+
3. Each contact receives warmth and attention scores based on cadence, open commitments, support signals, and recent notes.
|
|
60
|
+
4. The planner ranks next actions and writes a short message draft for each one.
|
|
61
|
+
|
|
62
|
+
The pipeline is intentionally small and transparent so personal relationship notes stay local and understandable.
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
python3 -m unittest discover -s tests
|
|
68
|
+
python3 app.py --no-open --data ./.kindred-keeper-data.json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Package build:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python3 -m build --no-isolation
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Project Layout
|
|
78
|
+
|
|
79
|
+
- `kindred_keeper/engine.py` contains note analysis, scoring, and planning.
|
|
80
|
+
- `kindred_keeper/storage.py` manages the local JSON workspace.
|
|
81
|
+
- `kindred_keeper/server.py` serves the local web app and JSON API.
|
|
82
|
+
- `static/` contains the browser UI.
|
|
83
|
+
- `tests/` covers extraction, summary scoring, and action planning.
|
|
84
|
+
|
|
85
|
+
## Roadmap
|
|
86
|
+
|
|
87
|
+
- Calendar export for planned check-ins.
|
|
88
|
+
- Import from a simple contacts CSV.
|
|
89
|
+
- Reminder notifications in a desktop wrapper.
|
|
90
|
+
- Optional encrypted data file.
|
|
91
|
+
- Better recurring-date handling for birthdays and anniversaries.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter, defaultdict
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import date, datetime, timedelta
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
STOPWORDS = {
|
|
12
|
+
"a",
|
|
13
|
+
"and",
|
|
14
|
+
"are",
|
|
15
|
+
"as",
|
|
16
|
+
"at",
|
|
17
|
+
"be",
|
|
18
|
+
"for",
|
|
19
|
+
"from",
|
|
20
|
+
"has",
|
|
21
|
+
"have",
|
|
22
|
+
"i",
|
|
23
|
+
"in",
|
|
24
|
+
"is",
|
|
25
|
+
"it",
|
|
26
|
+
"of",
|
|
27
|
+
"on",
|
|
28
|
+
"or",
|
|
29
|
+
"she",
|
|
30
|
+
"he",
|
|
31
|
+
"they",
|
|
32
|
+
"the",
|
|
33
|
+
"their",
|
|
34
|
+
"to",
|
|
35
|
+
"we",
|
|
36
|
+
"with",
|
|
37
|
+
"you",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
TRAINING = {
|
|
41
|
+
"celebration": [
|
|
42
|
+
"birthday dinner graduation promoted won anniversary new baby congratulations",
|
|
43
|
+
"party celebrate engagement wedding milestone good news proud achievement",
|
|
44
|
+
"finished marathon got accepted opening night launch recital performance",
|
|
45
|
+
],
|
|
46
|
+
"support": [
|
|
47
|
+
"surgery sick hospital grief difficult stressed burned out tired lonely",
|
|
48
|
+
"lost job breakup funeral caring parent overwhelmed anxious worried",
|
|
49
|
+
"treatment recovering diagnosis trouble moving house emergency",
|
|
50
|
+
],
|
|
51
|
+
"commitment": [
|
|
52
|
+
"promised send introduce follow up remind book tickets share recipe",
|
|
53
|
+
"owed call lend return plan coffee dinner help review",
|
|
54
|
+
"need to check ask about send photos schedule visit",
|
|
55
|
+
],
|
|
56
|
+
"interest": [
|
|
57
|
+
"loves gardening jazz hiking chess cooking photography soccer books",
|
|
58
|
+
"favorite restaurant tea dogs movies ceramics cycling baking",
|
|
59
|
+
"interested in travel history podcasts painting theatre yoga",
|
|
60
|
+
],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
POSITIVE_WORDS = {
|
|
64
|
+
"happy",
|
|
65
|
+
"excited",
|
|
66
|
+
"proud",
|
|
67
|
+
"great",
|
|
68
|
+
"good",
|
|
69
|
+
"celebrate",
|
|
70
|
+
"better",
|
|
71
|
+
"enjoyed",
|
|
72
|
+
"love",
|
|
73
|
+
"kind",
|
|
74
|
+
"fun",
|
|
75
|
+
}
|
|
76
|
+
NEGATIVE_WORDS = {
|
|
77
|
+
"sad",
|
|
78
|
+
"hard",
|
|
79
|
+
"difficult",
|
|
80
|
+
"sick",
|
|
81
|
+
"stressed",
|
|
82
|
+
"worried",
|
|
83
|
+
"lonely",
|
|
84
|
+
"tired",
|
|
85
|
+
"overwhelmed",
|
|
86
|
+
"grief",
|
|
87
|
+
"emergency",
|
|
88
|
+
"anxious",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
TOKEN_RE = re.compile(r"[a-z][a-z'-]{1,}")
|
|
92
|
+
DATE_RE = re.compile(
|
|
93
|
+
r"\b(?:(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+(\d{1,2})|(\d{4})-(\d{2})-(\d{2}))\b",
|
|
94
|
+
re.I,
|
|
95
|
+
)
|
|
96
|
+
PROMISE_RE = re.compile(
|
|
97
|
+
r"\b(?:promised|promise|owe|owed|need to|should|remember to|follow up|send|share|introduce|book|call|text|visit)\b[^.!\n]{0,90}",
|
|
98
|
+
re.I,
|
|
99
|
+
)
|
|
100
|
+
INTEREST_RE = re.compile(
|
|
101
|
+
r"\b(?:loves?|likes?|favorite|into|interested in|enjoys?)\s+([^.;\n]{2,80})",
|
|
102
|
+
re.I,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class Signal:
|
|
108
|
+
label: str
|
|
109
|
+
score: float
|
|
110
|
+
evidence: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class PlannedAction:
|
|
115
|
+
contact_id: str
|
|
116
|
+
contact_name: str
|
|
117
|
+
title: str
|
|
118
|
+
reason: str
|
|
119
|
+
priority: int
|
|
120
|
+
due: str
|
|
121
|
+
tone: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def tokenize(text: str) -> list[str]:
|
|
125
|
+
return [t for t in TOKEN_RE.findall(text.lower()) if t not in STOPWORDS]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class NaiveSignalClassifier:
|
|
129
|
+
def __init__(self) -> None:
|
|
130
|
+
self.label_counts: dict[str, Counter[str]] = {}
|
|
131
|
+
self.label_totals: dict[str, int] = {}
|
|
132
|
+
self.vocabulary: set[str] = set()
|
|
133
|
+
for label, examples in TRAINING.items():
|
|
134
|
+
counter: Counter[str] = Counter()
|
|
135
|
+
for example in examples:
|
|
136
|
+
counter.update(tokenize(example))
|
|
137
|
+
self.label_counts[label] = counter
|
|
138
|
+
self.label_totals[label] = sum(counter.values())
|
|
139
|
+
self.vocabulary.update(counter.keys())
|
|
140
|
+
|
|
141
|
+
def score(self, text: str) -> list[Signal]:
|
|
142
|
+
tokens = tokenize(text)
|
|
143
|
+
if not tokens:
|
|
144
|
+
return []
|
|
145
|
+
vocab_size = max(len(self.vocabulary), 1)
|
|
146
|
+
raw: dict[str, float] = {}
|
|
147
|
+
for label, counts in self.label_counts.items():
|
|
148
|
+
total = self.label_totals[label] + vocab_size
|
|
149
|
+
value = math.log(1 / len(self.label_counts))
|
|
150
|
+
for token in tokens:
|
|
151
|
+
value += math.log((counts[token] + 1) / total)
|
|
152
|
+
raw[label] = value
|
|
153
|
+
peak = max(raw.values())
|
|
154
|
+
exp = {label: math.exp(value - peak) for label, value in raw.items()}
|
|
155
|
+
total_exp = sum(exp.values()) or 1.0
|
|
156
|
+
ranked = []
|
|
157
|
+
for label, value in exp.items():
|
|
158
|
+
score = value / total_exp
|
|
159
|
+
if score >= 0.18:
|
|
160
|
+
ranked.append(Signal(label, round(score, 3), _evidence_for(label, text)))
|
|
161
|
+
return sorted(ranked, key=lambda item: item.score, reverse=True)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
CLASSIFIER = NaiveSignalClassifier()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _evidence_for(label: str, text: str) -> str:
|
|
168
|
+
words = set(TRAINING[label][0].split())
|
|
169
|
+
fragments = re.split(r"(?<=[.!?])\s+|\n+", text.strip())
|
|
170
|
+
for fragment in fragments:
|
|
171
|
+
if words.intersection(tokenize(fragment)):
|
|
172
|
+
return fragment.strip()[:180]
|
|
173
|
+
return text.strip()[:180]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def sentiment_score(text: str) -> int:
|
|
177
|
+
tokens = tokenize(text)
|
|
178
|
+
positives = sum(1 for token in tokens if token in POSITIVE_WORDS)
|
|
179
|
+
negatives = sum(1 for token in tokens if token in NEGATIVE_WORDS)
|
|
180
|
+
return max(-5, min(5, positives - negatives))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def extract_interests(text: str) -> list[str]:
|
|
184
|
+
found: list[str] = []
|
|
185
|
+
for match in INTEREST_RE.finditer(text):
|
|
186
|
+
phrase = re.sub(r"\band\b", ",", match.group(1), flags=re.I)
|
|
187
|
+
for part in re.split(r"[,/]| and ", phrase):
|
|
188
|
+
cleaned = re.sub(r"[^a-zA-Z0-9 '-]", "", part).strip().lower()
|
|
189
|
+
if 2 <= len(cleaned) <= 36:
|
|
190
|
+
found.append(cleaned)
|
|
191
|
+
return _unique(found)[:8]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def extract_promises(text: str) -> list[str]:
|
|
195
|
+
promises = []
|
|
196
|
+
for match in PROMISE_RE.finditer(text):
|
|
197
|
+
item = re.sub(r"\s+", " ", match.group(0)).strip(" .")
|
|
198
|
+
if len(item) > 5:
|
|
199
|
+
promises.append(item[0].upper() + item[1:])
|
|
200
|
+
return _unique(promises)[:8]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def extract_dates(text: str, today: date | None = None) -> list[str]:
|
|
204
|
+
today = today or date.today()
|
|
205
|
+
dates: list[str] = []
|
|
206
|
+
month_lookup = {
|
|
207
|
+
"jan": 1,
|
|
208
|
+
"feb": 2,
|
|
209
|
+
"mar": 3,
|
|
210
|
+
"apr": 4,
|
|
211
|
+
"may": 5,
|
|
212
|
+
"jun": 6,
|
|
213
|
+
"jul": 7,
|
|
214
|
+
"aug": 8,
|
|
215
|
+
"sep": 9,
|
|
216
|
+
"sept": 9,
|
|
217
|
+
"oct": 10,
|
|
218
|
+
"nov": 11,
|
|
219
|
+
"dec": 12,
|
|
220
|
+
}
|
|
221
|
+
for match in DATE_RE.finditer(text):
|
|
222
|
+
if match.group(1):
|
|
223
|
+
month = month_lookup[match.group(1).lower()[:3]]
|
|
224
|
+
day = int(match.group(2))
|
|
225
|
+
year = today.year
|
|
226
|
+
candidate = date(year, month, day)
|
|
227
|
+
if candidate < today - timedelta(days=7):
|
|
228
|
+
candidate = date(year + 1, month, day)
|
|
229
|
+
else:
|
|
230
|
+
candidate = date(int(match.group(3)), int(match.group(4)), int(match.group(5)))
|
|
231
|
+
dates.append(candidate.isoformat())
|
|
232
|
+
return _unique(dates)[:8]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def analyze_note(text: str, today: date | None = None) -> dict:
|
|
236
|
+
signals = CLASSIFIER.score(text)
|
|
237
|
+
return {
|
|
238
|
+
"signals": [signal.__dict__ for signal in signals],
|
|
239
|
+
"sentiment": sentiment_score(text),
|
|
240
|
+
"interests": extract_interests(text),
|
|
241
|
+
"promises": extract_promises(text),
|
|
242
|
+
"dates": extract_dates(text, today=today),
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def build_contact_summary(contact: dict, notes: Iterable[dict], today: date | None = None) -> dict:
|
|
247
|
+
today = today or date.today()
|
|
248
|
+
notes = sorted(notes, key=lambda row: row.get("created_at", ""), reverse=True)
|
|
249
|
+
all_interests: list[str] = list(contact.get("interests", []))
|
|
250
|
+
open_promises: list[str] = []
|
|
251
|
+
next_dates: list[str] = []
|
|
252
|
+
signal_counts: Counter[str] = Counter()
|
|
253
|
+
sentiment_total = 0
|
|
254
|
+
for note in notes:
|
|
255
|
+
analysis = note.get("analysis", {})
|
|
256
|
+
all_interests.extend(analysis.get("interests", []))
|
|
257
|
+
open_promises.extend(analysis.get("promises", []))
|
|
258
|
+
next_dates.extend(d for d in analysis.get("dates", []) if d >= today.isoformat())
|
|
259
|
+
sentiment_total += int(analysis.get("sentiment", 0))
|
|
260
|
+
for signal in analysis.get("signals", []):
|
|
261
|
+
signal_counts[signal["label"]] += 1
|
|
262
|
+
last_seen = contact.get("last_seen") or (notes[0]["created_at"][:10] if notes else "")
|
|
263
|
+
days_since = _days_since(last_seen, today)
|
|
264
|
+
cadence = int(contact.get("cadence_days") or 30)
|
|
265
|
+
warmth = max(0, min(100, 74 - min(days_since, 90) + min(len(notes) * 4, 18) + sentiment_total * 3))
|
|
266
|
+
attention = max(0, min(100, int((days_since / max(cadence, 1)) * 55) + len(open_promises) * 12 + signal_counts["support"] * 14))
|
|
267
|
+
return {
|
|
268
|
+
"id": contact["id"],
|
|
269
|
+
"name": contact["name"],
|
|
270
|
+
"relationship": contact.get("relationship", ""),
|
|
271
|
+
"last_seen": last_seen,
|
|
272
|
+
"days_since": days_since,
|
|
273
|
+
"cadence_days": cadence,
|
|
274
|
+
"warmth": warmth,
|
|
275
|
+
"attention": attention,
|
|
276
|
+
"interests": _unique(all_interests)[:8],
|
|
277
|
+
"open_promises": _unique(open_promises)[:6],
|
|
278
|
+
"upcoming_dates": sorted(_unique(next_dates))[:4],
|
|
279
|
+
"top_signals": signal_counts.most_common(4),
|
|
280
|
+
"note_count": len(notes),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def plan_actions(contacts: list[dict], notes_by_contact: dict[str, list[dict]], today: date | None = None) -> list[PlannedAction]:
|
|
285
|
+
today = today or date.today()
|
|
286
|
+
actions: list[PlannedAction] = []
|
|
287
|
+
for contact in contacts:
|
|
288
|
+
summary = build_contact_summary(contact, notes_by_contact.get(contact["id"], []), today=today)
|
|
289
|
+
due_base = today + timedelta(days=max(0, min(14, summary["cadence_days"] - summary["days_since"])))
|
|
290
|
+
if summary["open_promises"]:
|
|
291
|
+
actions.append(
|
|
292
|
+
PlannedAction(
|
|
293
|
+
contact["id"],
|
|
294
|
+
contact["name"],
|
|
295
|
+
summary["open_promises"][0],
|
|
296
|
+
"You wrote down a commitment that is still worth closing.",
|
|
297
|
+
min(100, summary["attention"] + 25),
|
|
298
|
+
today.isoformat(),
|
|
299
|
+
"clear and reliable",
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
if summary["upcoming_dates"]:
|
|
303
|
+
event_date = datetime.fromisoformat(summary["upcoming_dates"][0]).date()
|
|
304
|
+
days_until = (event_date - today).days
|
|
305
|
+
if 0 <= days_until <= 21:
|
|
306
|
+
actions.append(
|
|
307
|
+
PlannedAction(
|
|
308
|
+
contact["id"],
|
|
309
|
+
contact["name"],
|
|
310
|
+
f"Prepare a note for {event_date.strftime('%b %-d')}",
|
|
311
|
+
"An upcoming date appeared in your notes.",
|
|
312
|
+
85 - days_until,
|
|
313
|
+
max(today, event_date - timedelta(days=3)).isoformat(),
|
|
314
|
+
"warm and specific",
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
if summary["attention"] >= 45:
|
|
318
|
+
interest = summary["interests"][0] if summary["interests"] else contact.get("relationship", "life")
|
|
319
|
+
actions.append(
|
|
320
|
+
PlannedAction(
|
|
321
|
+
contact["id"],
|
|
322
|
+
contact["name"],
|
|
323
|
+
f"Send a check-in about {interest}",
|
|
324
|
+
f"It has been {summary['days_since']} days since the last touchpoint.",
|
|
325
|
+
summary["attention"],
|
|
326
|
+
due_base.isoformat(),
|
|
327
|
+
"low-pressure",
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
actions.sort(key=lambda item: (-item.priority, item.due, item.contact_name))
|
|
331
|
+
return actions[:10]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def suggest_message(contact: dict, summary: dict, action: PlannedAction) -> str:
|
|
335
|
+
interest = summary["interests"][0] if summary["interests"] else "how things are going"
|
|
336
|
+
if "Prepare a note" in action.title:
|
|
337
|
+
return f"Hi {contact['name'].split()[0]}, I remembered {action.title.replace('Prepare a note for ', '')} is coming up and wanted to send something thoughtful. Hope it is a good one."
|
|
338
|
+
if summary["open_promises"] and action.title == summary["open_promises"][0]:
|
|
339
|
+
return f"Hi {contact['name'].split()[0]}, I remembered I said I would {action.title[0].lower() + action.title[1:]}. I am on it and wanted to close the loop."
|
|
340
|
+
return f"Hi {contact['name'].split()[0]}, I was thinking about you and {interest}. How has that been going lately?"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _days_since(value: str, today: date) -> int:
|
|
344
|
+
if not value:
|
|
345
|
+
return 999
|
|
346
|
+
try:
|
|
347
|
+
return max(0, (today - datetime.fromisoformat(value[:10]).date()).days)
|
|
348
|
+
except ValueError:
|
|
349
|
+
return 999
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _unique(items: Iterable[str]) -> list[str]:
|
|
353
|
+
seen = set()
|
|
354
|
+
result = []
|
|
355
|
+
for item in items:
|
|
356
|
+
key = item.lower().strip()
|
|
357
|
+
if key and key not in seen:
|
|
358
|
+
seen.add(key)
|
|
359
|
+
result.append(item.strip())
|
|
360
|
+
return result
|