Skip to content

Commit 5ec4299

Browse files
ndbroadbentclaude
andcommitted
Change score system to 0-5 scale and remove isGeneric field
- Update classification prompt to request 0-5 scores for fun/interesting - Add calculateCombinedScore() utility to avoid formula duplication - Round scores to 1 decimal place at parse time (conf to 2 decimals) - Remove isGeneric field entirely - derive geocodability from location fields - Update prompt to not default to home country for generic activities - Update all tests and fixtures for new score scale 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 99842d8 commit 5ec4299

18 files changed

Lines changed: 196 additions & 217 deletions

File tree

src/classifier/index.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77

88
import { generateClassifierCacheKey } from '../cache/key'
99
import type { ResponseCache } from '../cache/types'
10-
import type {
11-
ActivityCategory,
12-
CandidateMessage,
13-
ClassifiedActivity,
14-
ClassifierConfig,
15-
Result
10+
import {
11+
type ActivityCategory,
12+
type CandidateMessage,
13+
type ClassifiedActivity,
14+
type ClassifierConfig,
15+
calculateCombinedScore,
16+
type Result
1617
} from '../types'
1718
import { DEFAULT_MODELS } from './models'
1819
import {
@@ -75,9 +76,10 @@ function toClassifiedActivity(
7576
const capitalizedTitle = title.charAt(0).toUpperCase() + title.slice(1)
7677

7778
// Build activity without ID first
79+
// Scores are 0-5 scale from the AI
7880
const funScore = response.fun
7981
const interestingScore = response.int
80-
const score = interestingScore * 2 + funScore
82+
const score = calculateCombinedScore(funScore, interestingScore)
8183

8284
const activity = {
8385
activity: capitalizedTitle,
@@ -94,7 +96,6 @@ function toClassifiedActivity(
9496
message: resolvedMessage.content
9597
}
9698
],
97-
isGeneric: response.gen,
9899
isCompound: response.com,
99100
action: response.act,
100101
actionOriginal: response.act_orig,

src/classifier/prompt.test.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ describe('Classifier Prompt', () => {
106106
expect(prompt).toContain('"venue"')
107107
expect(prompt).toContain('"city"')
108108
expect(prompt).toContain('"country"')
109-
expect(prompt).toContain('"gen"')
110109
expect(prompt).toContain('"com"')
111110
})
112111

@@ -261,7 +260,6 @@ describe('Classifier Prompt', () => {
261260
expect(parsed[0]?.fun).toBe(0.9)
262261
expect(parsed[0]?.cat).toBe('restaurant')
263262
expect(parsed[0]?.conf).toBe(0.95)
264-
expect(parsed[0]?.gen).toBe(false)
265263
expect(parsed[0]?.com).toBe(true)
266264
expect(parsed[0]?.act).toBe('eat')
267265
expect(parsed[0]?.venue).toBe('Italian place')
@@ -271,8 +269,8 @@ describe('Classifier Prompt', () => {
271269

272270
it('parses multiple items', () => {
273271
const response = `[
274-
{"msg": 1, "title": "Hiking", "fun": 0.8, "int": 0.6, "cat": "hike", "conf": 0.9, "gen": true, "com": true, "act": "hike", "act_orig": "hiking", "obj": null, "obj_orig": null, "venue": null, "city": "Mountains", "region": null, "country": null},
275-
{"msg": 2, "title": "Vet visit", "fun": 0.1, "int": 0.2, "cat": "other", "conf": 0.85, "gen": false, "com": true, "act": "visit", "act_orig": "visit", "obj": "vet", "obj_orig": "vet", "venue": null, "city": null, "region": null, "country": null}
272+
{"msg": 1, "title": "Hiking", "fun": 0.8, "int": 0.6, "cat": "hike", "conf": 0.9, "com": true, "act": "hike", "act_orig": "hiking", "obj": null, "obj_orig": null, "venue": null, "city": "Mountains", "region": null, "country": null},
273+
{"msg": 2, "title": "Vet visit", "fun": 0.1, "int": 0.2, "cat": "other", "conf": 0.85, "com": true, "act": "visit", "act_orig": "visit", "obj": "vet", "obj_orig": "vet", "venue": null, "city": null, "region": null, "country": null}
276274
]`
277275

278276
const parsed = parseClassificationResponse(response)
@@ -283,7 +281,7 @@ describe('Classifier Prompt', () => {
283281

284282
it('handles response with markdown code block', () => {
285283
const response = `\`\`\`json
286-
[{"msg": 1, "title": "Beach day", "fun": 0.9, "int": 0.5, "cat": "nature", "conf": 0.95, "gen": true, "com": true, "act": "beach", "act_orig": "beach", "obj": null, "obj_orig": null, "venue": null, "city": "Malibu", "region": "California", "country": "USA"}]
284+
[{"msg": 1, "title": "Beach day", "fun": 0.9, "int": 0.5, "cat": "nature", "conf": 0.95, "com": true, "act": "beach", "act_orig": "beach", "obj": null, "obj_orig": null, "venue": null, "city": "Malibu", "region": "California", "country": "USA"}]
287285
\`\`\``
288286

289287
const parsed = parseClassificationResponse(response)
@@ -295,7 +293,7 @@ describe('Classifier Prompt', () => {
295293
it('handles response with extra text around JSON', () => {
296294
const response = `Here is the classification:
297295
298-
[{"msg": 1, "title": "Concert", "fun": 0.85, "int": 0.5, "cat": "concert", "conf": 0.9, "gen": false, "com": true, "act": "attend", "act_orig": "concert", "obj": "concert", "obj_orig": "concert", "venue": "Madison Square Garden", "city": "New York", "region": "NY", "country": "USA"}]
296+
[{"msg": 1, "title": "Concert", "fun": 0.85, "int": 0.5, "cat": "concert", "conf": 0.9, "com": true, "act": "attend", "act_orig": "concert", "obj": "concert", "obj_orig": "concert", "venue": "Madison Square Garden", "city": "New York", "region": "NY", "country": "USA"}]
299297
300298
Hope this helps!`
301299

@@ -306,7 +304,7 @@ Hope this helps!`
306304
})
307305

308306
it('handles null location fields', () => {
309-
const response = `[{"msg": 1, "title": "Something fun", "fun": 0.7, "int": 0.5, "cat": "other", "conf": 0.8, "gen": true, "com": true, "act": "do", "act_orig": "do", "obj": null, "obj_orig": null, "venue": null, "city": null, "region": null, "country": null}]`
307+
const response = `[{"msg": 1, "title": "Something fun", "fun": 0.7, "int": 0.5, "cat": "other", "conf": 0.8, "com": true, "act": "do", "act_orig": "do", "obj": null, "obj_orig": null, "venue": null, "city": null, "region": null, "country": null}]`
310308

311309
const parsed = parseClassificationResponse(response)
312310

@@ -321,28 +319,27 @@ Hope this helps!`
321319
const parsed = parseClassificationResponse(response)
322320

323321
// Defaults: gen=true, com=true
324-
expect(parsed[0]?.gen).toBe(true)
325322
expect(parsed[0]?.com).toBe(true)
326323
})
327324

328325
it('parses string-typed numbers (gpt-5-nano compatibility)', () => {
329-
// Some models return numbers as strings
330-
const response = `[{"msg": "168", "title": "Test", "fun": "0.85", "int": "0.5", "cat": "other", "conf": "0.9", "gen": true, "com": true}]`
326+
// Some models return numbers as strings. Scores are 0-5 scale.
327+
const response = `[{"msg": "168", "title": "Test", "fun": "4.25", "int": "3.5", "cat": "other", "conf": "0.9", "com": true}]`
331328

332329
const parsed = parseClassificationResponse(response)
333330

334331
expect(parsed[0]?.msg).toBe(168)
335-
expect(parsed[0]?.fun).toBe(0.85)
332+
expect(parsed[0]?.fun).toBe(4.3) // 4.25 rounds to 4.3
333+
expect(parsed[0]?.int).toBe(3.5)
336334
expect(parsed[0]?.conf).toBe(0.9)
337335
})
338336

339337
it('parses string-typed booleans', () => {
340-
// Some models return booleans as strings
341-
const response = `[{"msg": 1, "title": "Test", "fun": 0.5, "int": 0.5, "cat": "other", "conf": 0.5, "gen": "false", "com": "true"}]`
338+
// Some models return booleans as strings. Scores are 0-5 scale.
339+
const response = `[{"msg": 1, "title": "Test", "fun": 3.5, "int": 2.5, "cat": "other", "conf": 0.5, "gen": "false", "com": "true"}]`
342340

343341
const parsed = parseClassificationResponse(response)
344342

345-
expect(parsed[0]?.gen).toBe(false)
346343
expect(parsed[0]?.com).toBe(true)
347344
})
348345

src/classifier/prompt.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,10 @@ Return JSON array with ONLY activities worth saving. Skip non-activities entirel
177177
{
178178
"msg": <message_id>,
179179
${offsetField} "title": "<activity description, under 100 chars, fix any typos (e.g., 'ballon'→'balloon')>",
180-
"fun": <0.0-1.0 how fun/enjoyable>,
181-
"int": <0.0-1.0 how interesting/unique>,
180+
"fun": <0.0-5.0 how fun/enjoyable>,
181+
"int": <0.0-5.0 how interesting/unique>,
182182
"cat": "<category>",
183183
"conf": <0.0-1.0 your confidence>,
184-
"gen": <true if generic, no specific venue/URL>,
185184
"com": <true if compound/complex activity that one JSON object can't fully represent>,
186185
"act": "<normalized action: go, hike, eat, watch, play, visit, etc. (always required)>",
187186
"act_orig": "<original action word>",
@@ -205,7 +204,7 @@ const SHARED_KEYWORDS_SECTION = `KEYWORDS (kw): Include up to 3 keywords for sto
205204
- DO NOT include any generic terms that may dilute the search query. For example, "play paintball" is a much better query WITHOUT generic keywords like "action, game, team" (which return images of football and basketball.) Include no keywords at all if the act/obj/venue are already specific.`
206205

207206
function buildLocationSection(homeCountry: string): string {
208-
return `LOCATION: Fill city/region/country if mentioned or obvious from context. For ambiguous names (e.g., "Omaha"), assume the user's home country (${homeCountry}). Venue can only be a specific place and not a general region.`
207+
return `LOCATION: Only fill venue/city/region/country if explicitly mentioned or strongly implied. Do NOT default to the user's home country for generic activities like "watch a movie" or "play tennis". For ambiguous place names (e.g., "Omaha"), assume the user's home country (${homeCountry}). Venue can only be a specific place and not a general region.`
209208
}
210209

211210
const SHARED_CATEGORIES_SECTION = `CATEGORIES: ${VALID_CATEGORIES.join(', ')}
@@ -266,9 +265,10 @@ ${SHARED_CATEGORIES_SECTION}
266265
${SHARED_NORMALIZATION}
267266
268267
EXAMPLES:
269-
- "Go tramping in Queenstown" → act:"hike", city:"Queenstown", gen:false
270-
- "Watch a movie" → act:"watch", obj:"movie", gen:true
271-
- "Go to Coffee Lab" → act:"visit", venue:"Coffee Lab", gen:false
268+
- "Go tramping in Queenstown" → act:"hike", city:"Queenstown"
269+
- "Take a cable car ride in San Francisco" → act:"ride", obj:"cable car", city:"San Francisco"
270+
- "Watch a movie" → act:"watch", obj:"movie"
271+
- "Go to Coffee Lab" → act:"visit", venue:"Coffee Lab"
272272
- "Let's visit Omaha" (user in NZ) → city:"Omaha", country:"New Zealand"
273273
- "Go to Iceland and see the aurora" → act:"travel", country:"Iceland", com:true (two activities: travel + aurora viewing)
274274

src/classifier/response-parser.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,12 @@ export interface ParsedClassification {
99
/** Message offset - 0 for suggestions, negative for agreements pointing to earlier messages */
1010
off: number
1111
title: string | null
12-
/** How fun/enjoyable is this activity? 0=boring, 1=exciting */
12+
/** How fun/enjoyable is this activity? 0.0-5.0 scale */
1313
fun: number
14-
/** How interesting/unique is this activity? 0=common/mundane, 1=rare/novel */
14+
/** How interesting/unique is this activity? 0.0-5.0 scale */
1515
int: number
1616
cat: string
1717
conf: number
18-
gen: boolean
1918
com: boolean
2019
act: string | null
2120
act_orig: string | null
@@ -47,17 +46,23 @@ function parseString(val: unknown): string | null {
4746
return typeof val === 'string' && val.trim() ? val : null
4847
}
4948

50-
function parseNumber(val: unknown, fallback: number, clamp = true): number {
49+
/** Round to N decimal places */
50+
function roundTo(n: number, decimals: number): number {
51+
const factor = 10 ** decimals
52+
return Math.round(n * factor) / factor
53+
}
54+
55+
function parseNumber(val: unknown, fallback: number, max = 1, roundDecimals?: number): number {
56+
let result = fallback
5157
if (typeof val === 'number') {
52-
return clamp ? Math.max(0, Math.min(1, val)) : val
53-
}
54-
if (typeof val === 'string') {
58+
result = Math.max(0, Math.min(max, val))
59+
} else if (typeof val === 'string') {
5560
const parsed = Number.parseFloat(val)
5661
if (!Number.isNaN(parsed)) {
57-
return clamp ? Math.max(0, Math.min(1, parsed)) : parsed
62+
result = Math.max(0, Math.min(max, parsed))
5863
}
5964
}
60-
return fallback
65+
return roundDecimals !== undefined ? roundTo(result, roundDecimals) : result
6166
}
6267

6368
function parseBoolean(val: unknown, fallback: boolean): boolean {
@@ -76,14 +81,13 @@ function parseStringArray(val: unknown): string[] {
7681

7782
function parseItem(obj: Record<string, unknown>): ParsedClassification {
7883
return {
79-
msg: parseNumber(obj.msg, 0, false), // msg is an ID, not clamped to 0-1
80-
off: parseNumber(obj.off, 0, false), // offset, not clamped (usually 0 or negative)
84+
msg: parseNumber(obj.msg, 0, Number.MAX_VALUE), // msg is an ID, not clamped
85+
off: parseNumber(obj.off, 0, Number.MAX_VALUE), // offset, not clamped (usually 0 or negative)
8186
title: parseString(obj.title),
82-
fun: parseNumber(obj.fun, 0.5),
83-
int: parseNumber(obj.int, 0.5),
87+
fun: parseNumber(obj.fun, 2.5, 5, 1), // 0-5 scale, 1 decimal
88+
int: parseNumber(obj.int, 2.5, 5, 1), // 0-5 scale, 1 decimal
8489
cat: typeof obj.cat === 'string' ? obj.cat : 'other',
85-
conf: parseNumber(obj.conf, 0.5),
86-
gen: parseBoolean(obj.gen, true),
90+
conf: parseNumber(obj.conf, 0.5, 1, 2), // 0-1 scale, 2 decimals (percentage)
8791
com: parseBoolean(obj.com, true),
8892
act: parseString(obj.act),
8993
act_orig: parseString(obj.act_orig),

src/cli/aggregation.test.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -292,37 +292,39 @@ describe('Aggregation Module', () => {
292292
})
293293

294294
it('averages funScore and interestingScore across merged activities', () => {
295+
// Scores are 0-5 scale
295296
const act1 = createActivity({
296297
id: 1,
297298
activity: 'pottery class',
298299
action: 'take',
299300
object: 'class',
300-
funScore: 0.8,
301-
interestingScore: 0.6
301+
funScore: 4.0,
302+
interestingScore: 3.0
302303
})
303304
const act2 = createActivity({
304305
id: 2,
305306
activity: 'Pottery Class',
306307
action: 'take',
307308
object: 'class',
308-
funScore: 0.6,
309-
interestingScore: 0.4
309+
funScore: 3.0,
310+
interestingScore: 2.0
310311
})
311312
const act3 = createActivity({
312313
id: 3,
313314
activity: 'pottery classes',
314315
action: 'take',
315316
object: 'class',
316-
funScore: 0.7,
317-
interestingScore: 0.5
317+
funScore: 3.5,
318+
interestingScore: 2.5
318319
})
319320

320321
const result = aggregateActivities([act1, act2, act3])
321322

322323
expect(result).toHaveLength(1)
323-
expect(result[0]?.funScore).toBe(0.7) // (0.8 + 0.6 + 0.7) / 3 = 0.7
324-
expect(result[0]?.interestingScore).toBe(0.5) // (0.6 + 0.4 + 0.5) / 3 = 0.5
325-
expect(result[0]?.score).toBe(1.7) // 0.5 * 2 + 0.7 = 1.7
324+
expect(result[0]?.funScore).toBe(3.5) // (4.0 + 3.0 + 3.5) / 3 = 3.5
325+
expect(result[0]?.interestingScore).toBe(2.5) // (3.0 + 2.0 + 2.5) / 3 = 2.5
326+
// Combined score = (int * 2 + fun) / 3 = (2.5 * 2 + 3.5) / 3 = 2.8
327+
expect(result[0]?.score).toBe(2.8)
326328
})
327329

328330
it('keeps first occurrence as primary when merging', () => {

src/cli/aggregation.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* This is an orchestrator concern (CLI), not core library.
88
*/
99

10-
import type { ClassifiedActivity } from '../types'
10+
import { type ClassifiedActivity, calculateCombinedScore } from '../types'
1111

1212
/**
1313
* Normalize a string for comparison (lowercase, trim, collapse whitespace).
@@ -173,13 +173,13 @@ export function aggregateActivities(
173173
// Merge duplicates
174174
const allMessages = matches.flatMap((a) => a.messages)
175175

176-
// Average scores across all matches
177-
const avgFunScore = round(matches.reduce((sum, a) => sum + a.funScore, 0) / matches.length, 2)
176+
// Average scores across all matches (1 decimal place)
177+
const avgFunScore = round(matches.reduce((sum, a) => sum + a.funScore, 0) / matches.length, 1)
178178
const avgInterestingScore = round(
179179
matches.reduce((sum, a) => sum + a.interestingScore, 0) / matches.length,
180-
2
180+
1
181181
)
182-
const newScore = round(avgInterestingScore * 2 + avgFunScore, 1)
182+
const newScore = calculateCombinedScore(avgFunScore, avgInterestingScore)
183183

184184
// Create merged activity (primary keeps its fields, just update messages/scores)
185185
result.push({

src/cli/commands/classify.test.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ describe('toOutputActivity', () => {
3131
message: 'We should do the Karangahake Gorge hike!'
3232
}
3333
],
34-
isGeneric: false,
3534
isCompound: false,
3635
action: 'hike',
3736
actionOriginal: 'hike',
@@ -60,7 +59,6 @@ describe('toOutputActivity', () => {
6059
expect(output.city).toBeNull()
6160
expect(output.region).toBeNull()
6261
expect(output.country).toBe('New Zealand')
63-
expect(output.isGeneric).toBe(false)
6462
expect(output.isCompound).toBe(false)
6563
expect(output.interestingScore).toBe(0.8)
6664
expect(output.funScore).toBe(0.9)
@@ -152,7 +150,6 @@ describe('buildClassifyOutput', () => {
152150
message: "Let's go hiking"
153151
}
154152
],
155-
isGeneric: false,
156153
action: 'hike',
157154
actionOriginal: 'hiking',
158155
region: 'Alps',
@@ -188,7 +185,6 @@ describe('buildClassifyOutput', () => {
188185
activity: 'activity two',
189186
category: 'food',
190187
messages: [{ id: 2, sender: 'B', timestamp: new Date(), message: 'msg 2' }],
191-
isGeneric: false,
192188
action: 'eat',
193189
actionOriginal: 'eat',
194190
venue: 'Some Restaurant',

src/cli/commands/classify.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ export interface ClassifyOutputActivity {
3131
city: string | null
3232
region: string | null
3333
country: string | null
34-
isGeneric: boolean
3534
isCompound: boolean
3635
interestingScore: number
3736
funScore: number
@@ -62,7 +61,6 @@ export function toOutputActivity(a: ClassifiedActivity): ClassifyOutputActivity
6261
city: a.city,
6362
region: a.region,
6463
country: a.country,
65-
isGeneric: a.isGeneric,
6664
isCompound: a.isCompound,
6765
interestingScore: a.interestingScore,
6866
funScore: a.funScore

0 commit comments

Comments
 (0)