Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cause-assist/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ LLM-backed helpers for CauseStarter, defaulting to **Grok 4.5** via the xAI API:
4. **Legacy statement suggester** — preserve the main → supporting workflow for existing causes.
5. **Implication check and safety filter** — verify arrows and apply operational acceptable-use rules.
6. **Coherence check + worker attestation helpers** — construction-only roster judgment (planks match summary, no riders); separate prompt and model config from generation. The trusted [`coherence-badge-worker`](../coherence-badge-worker/) imports the binding/judgment helpers and writes positive-only badges as the **CauseStarter site operator** (`msg.sender`), never the founder.
7. **Bridge-cluster wording verbs** — one-shot `draft-modified-plank`, `draft-stand-in-sliver`, `draft-bridge-plank`, and `critique-triple`. These help a human author a cluster; they are not a chat and they never write a standing strategy prompt. Product intent: [`docs/founder/bridge-cluster-wording-help.md`](../docs/founder/bridge-cluster-wording-help.md), [`docs/founder/the-other-cause.md`](../docs/founder/the-other-cause.md).

The three plank-first capabilities run as cause-assist-owned strategies on the shared bridge-creator statement engine. They share execution machinery and pattern techniques with bridge creation, but never its mediation strategy prompt.

Expand All @@ -31,6 +32,10 @@ See `src/statementGuidance.ts` and the Implication Attester evaluator prompt for
| POST | `/sharpen-plank` | `{ plank, causeDescription? }` | Critique + optional reword against the attestable + signable bar (callers should treat `plank` as a suggestion, not auto-apply) |
| POST | `/draft-anchor` | `{ planks[] }` | Deterministic disjunctive anchor with verbatim planks and plank→anchor check payloads |
| POST | `/suggest-mediator-scaffold` | `{ foundingStatement, name? }` | Editable mediator identity, side labels, and complete starting anchor triples; never a strategy prompt |
| POST | `/draft-modified-plank` | `{ parentPlanks[], currentDraft?, sideLabel?, mustNotConcede?, complaint? }` | One modified-plank proposal for a human-authored bridge cluster. Not a chat turn. Refuses empty parents. |
| POST | `/draft-stand-in-sliver` | `{ sideLabel, bullets?, mustNotCaricature?, complaint?, currentDraft? }` | Thin roster for a camp with no published cause. Not a modified-plank call. |
| POST | `/draft-bridge-plank` | `{ modifiedSides[{ label?, planks[] }], currentDraft?, complaint? }` | One shared-platform plank from ≥2 modified sides. Strips justifications. |
| POST | `/critique-triple` | `{ modifiedPlanks[], bridgePlank }` | Objections and justification-leak warnings only — no rewrite |
| POST | `/check-implications` | `{ mainStatement, supportingStatements[] }` | Per-pair implies / confidence / reasoning |
| POST | `/safety-check` | `{ items: [{ text, fieldLabel? }] }` | Per-item allow/deny + user-facing explanation |
| POST | `/check-coherence` | `{ rosterCid, title, summary, planks[], mediatorBlurb? }` | Positive-only construction check for a would-be roster CID (preview; no chain write; may use heuristic without an API key) |
Expand Down
4 changes: 4 additions & 0 deletions cause-assist/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ describe('cause-assist request guards', () => {
assert.equal((await post(baseUrl, '/atomize', { description: '' })).status, 400)
assert.equal((await post(baseUrl, '/sharpen-plank', { plank: '' })).status, 400)
assert.equal((await post(baseUrl, '/draft-anchor', { planks: ['only one'] })).status, 400)
assert.equal((await post(baseUrl, '/draft-modified-plank', { parentPlanks: [] })).status, 400)
assert.equal((await post(baseUrl, '/draft-stand-in-sliver', { sideLabel: '' })).status, 400)
assert.equal((await post(baseUrl, '/draft-bridge-plank', { modifiedSides: [{ planks: ['only one side'] }] })).status, 400)
assert.equal((await post(baseUrl, '/critique-triple', { modifiedPlanks: ['only one'], bridgePlank: 'shared' })).status, 400)

const planks = ['The creek should be clean.', 'Oak Street should be safe at night.']
const response = await post(baseUrl, '/draft-anchor', { planks })
Expand Down
142 changes: 142 additions & 0 deletions cause-assist/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { checkSafety } from './safetyFilter.js'
import { checkImplications } from './implicationCheck.js'
import { atomizeCause, draftDisjunctiveAnchor, sharpenPlank } from './plankStrategies.js'
import { suggestMediatorScaffold } from './mediatorScaffold.js'
import { critiqueTriple, draftBridgePlank, draftModifiedPlank, draftStandInSliver } from './bridgeClusterAssist.js'
import { checkCoherence } from './coherenceCheck.js'
import {
getCoherenceAttesterAddress,
Expand All @@ -20,6 +21,10 @@ import type {
SharpenPlankRequest,
SuggestStatementsRequest,
SuggestMediatorScaffoldRequest,
CritiqueTripleRequest,
DraftBridgePlankRequest,
DraftModifiedPlankRequest,
DraftStandInSliverRequest,
} from './types.js'

const MAX_STATEMENT_LENGTH = 2_000
Expand Down Expand Up @@ -65,6 +70,10 @@ export function createCauseAssistApp(config: CauseAssistConfig): express.Express
'/sharpen-plank',
'/draft-anchor',
'/suggest-mediator-scaffold',
'/draft-modified-plank',
'/draft-stand-in-sliver',
'/draft-bridge-plank',
'/critique-triple',
'/check-implications',
'/safety-check',
'/check-coherence',
Expand Down Expand Up @@ -176,6 +185,139 @@ export function createCauseAssistApp(config: CauseAssistConfig): express.Express
} catch (error) { next(error) }
})

app.post('/draft-modified-plank', async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as DraftModifiedPlankRequest
if (
!Array.isArray(body?.parentPlanks)
|| body.parentPlanks.length < 1
|| body.parentPlanks.length > MAX_EXISTING_STATEMENTS
|| body.parentPlanks.some((item) => !validStatement(item))
) {
invalidRequest(res, `parentPlanks must contain 1–${MAX_EXISTING_STATEMENTS} valid statements`)
return
}
if (body.currentDraft !== undefined && !validStatement(body.currentDraft)) {
invalidRequest(res, `currentDraft must be a valid statement when provided`)
return
}
if (body.sideLabel !== undefined && (typeof body.sideLabel !== 'string' || body.sideLabel.length > MAX_FIELD_LABEL_LENGTH)) {
invalidRequest(res, `sideLabel must be at most ${MAX_FIELD_LABEL_LENGTH} characters`)
return
}
if (body.mustNotConcede !== undefined && !validStatement(body.mustNotConcede)) {
invalidRequest(res, `mustNotConcede must be a valid statement when provided`)
return
}
if (body.complaint !== undefined && !validStatement(body.complaint)) {
invalidRequest(res, `complaint must be a valid statement when provided`)
return
}
res.json(await draftModifiedPlank(body, config))
} catch (error) { next(error) }
})

app.post('/draft-stand-in-sliver', async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as DraftStandInSliverRequest
if (!validStatement(body?.sideLabel) || body.sideLabel.length > MAX_FIELD_LABEL_LENGTH) {
invalidRequest(res, `sideLabel must be a non-empty label of at most ${MAX_FIELD_LABEL_LENGTH} characters`)
return
}
if (body.bullets !== undefined && (
!Array.isArray(body.bullets)
|| body.bullets.length > MAX_EXISTING_STATEMENTS
|| body.bullets.some((item) => !validStatement(item))
)) {
invalidRequest(res, `bullets must be 0–${MAX_EXISTING_STATEMENTS} valid statements when provided`)
return
}
if (body.mustNotCaricature !== undefined && !validStatement(body.mustNotCaricature)) {
invalidRequest(res, 'mustNotCaricature must be a valid statement when provided')
return
}
if (body.complaint !== undefined && !validStatement(body.complaint)) {
invalidRequest(res, 'complaint must be a valid statement when provided')
return
}
const draft = body.currentDraft
if (draft !== undefined) {
if (!draft || typeof draft !== 'object') {
invalidRequest(res, 'currentDraft must be an object when provided')
return
}
if (draft.title !== undefined && (typeof draft.title !== 'string' || draft.title.length > MAX_FIELD_LABEL_LENGTH)) {
invalidRequest(res, `currentDraft.title must be at most ${MAX_FIELD_LABEL_LENGTH} characters`)
return
}
if (draft.summary !== undefined && (typeof draft.summary !== 'string' || draft.summary.length > MAX_STATEMENT_LENGTH)) {
invalidRequest(res, 'currentDraft.summary is too long')
return
}
if (draft.planks !== undefined && (
!Array.isArray(draft.planks)
|| draft.planks.length > MAX_EXISTING_STATEMENTS
|| draft.planks.some((item) => item !== '' && !validStatement(item))
)) {
invalidRequest(res, 'currentDraft.planks must be valid statements when provided')
return
}
}
res.json(await draftStandInSliver(body, config))
} catch (error) { next(error) }
})

app.post('/draft-bridge-plank', async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as DraftBridgePlankRequest
if (
!Array.isArray(body?.modifiedSides)
|| body.modifiedSides.length < 2
|| body.modifiedSides.length > 6
|| body.modifiedSides.some((side) => (
!side
|| (side.label !== undefined && (typeof side.label !== 'string' || side.label.length > MAX_FIELD_LABEL_LENGTH))
|| !Array.isArray(side.planks)
|| side.planks.length < 1
|| side.planks.length > MAX_EXISTING_STATEMENTS
|| side.planks.some((item) => !validStatement(item))
))
) {
invalidRequest(res, 'modifiedSides must be 2–6 sides, each with 1–20 valid planks')
return
}
if (body.currentDraft !== undefined && !validStatement(body.currentDraft)) {
invalidRequest(res, `currentDraft must be a valid statement when provided`)
return
}
if (body.complaint !== undefined && !validStatement(body.complaint)) {
invalidRequest(res, `complaint must be a valid statement when provided`)
return
}
res.json(await draftBridgePlank(body, config))
} catch (error) { next(error) }
})

app.post('/critique-triple', async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as CritiqueTripleRequest
if (
!Array.isArray(body?.modifiedPlanks)
|| body.modifiedPlanks.length < 2
|| body.modifiedPlanks.length > MAX_EXISTING_STATEMENTS
|| body.modifiedPlanks.some((item) => !validStatement(item))
) {
invalidRequest(res, `modifiedPlanks must contain 2–${MAX_EXISTING_STATEMENTS} valid statements`)
return
}
if (!validStatement(body.bridgePlank)) {
invalidRequest(res, `bridgePlank is required and must be at most ${MAX_STATEMENT_LENGTH} characters`)
return
}
res.json(await critiqueTriple(body, config))
} catch (error) { next(error) }
})

app.post('/check-implications', async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as CheckImplicationsRequest
Expand Down
97 changes: 97 additions & 0 deletions cause-assist/src/bridgeClusterAssist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict'
import { describe, it } from 'mocha'
import type { LlmJsonRequest } from '@commonality/attester-core'
import { critiqueTriple, draftBridgePlank, draftModifiedPlank, draftStandInSliver } from './bridgeClusterAssist.js'
import type { CauseAssistConfig } from './types.js'

const config: CauseAssistConfig = {
apiKey: 'key', apiBaseUrl: 'https://example.test/v1', suggestModel: 'model',
safetyModel: 'model', implicationModel: 'model', coherenceModel: 'test', port: 0,
}

describe('bridge cluster wording verbs', () => {
it('drafts a modified plank from parent texts without writing a strategy prompt', async () => {
const result = await draftModifiedPlank({
parentPlanks: ['Marriage is a covenant and children are a blessing.'],
sideLabel: 'practising Christians',
mustNotConcede: 'Do not reduce this to outcome data.',
}, config, async <T>(request: LlmJsonRequest) => {
assert.match(request.systemPrompt, /human remains the publisher/i)
assert.doesNotMatch(request.systemPrompt, /strategy prompt you should write/i)
assert.match(request.userPrompt, /must_not_concede/)
return { plank: 'Marriage and children are among the best things God gives us, and I want family formation to be a normal, achievable thing.', rationale: 'Keeps covenant language.', warnings: [] } as T
})
assert.equal(result.source, 'llm')
assert.match(result.plank, /God/)
})

it('drafts a bridge plank from two modified sides', async () => {
const result = await draftBridgePlank({
modifiedSides: [
{ label: 'Christians', planks: ['God gives marriage; make family formation achievable.'] },
{ label: 'secular conservatives', planks: ['The data on two-parent households is not close.'] },
],
}, config, async <T>(request: LlmJsonRequest) => {
assert.match(request.systemPrompt, /justifications/i)
return { plank: 'It should be easier than it currently is for people to marry and raise children.', rationale: 'Conclusion only.', warnings: [] } as T
})
assert.equal(result.source, 'llm')
assert.match(result.plank, /easier/)
})

it('critiques a triple without rewriting', async () => {
const result = await critiqueTriple({
modifiedPlanks: [
'Marriage is a covenant God gives us.',
'Kids do better with two committed parents.',
],
bridgePlank: 'Marriage is a gift from God and also the data says so.',
}, config, async <T>(request: LlmJsonRequest) => {
assert.match(request.systemPrompt, /Do not rewrite/)
return {
objections: ['Shared plank requires a theological premise.'],
leakWarnings: ['God-talk leaked into the bridge plank.'],
} as T
})
assert.equal(result.source, 'llm')
assert.equal(result.objections.length, 1)
assert.equal(result.leakWarnings.length, 1)
})

it('drafts a stand-in sliver without treating it as a modified parent', async () => {
const result = await draftStandInSliver({
sideLabel: 'secular conservatives',
bullets: ['Two-parent households have better measured outcomes.'],
mustNotCaricature: 'Do not write this as anti-religion.',
}, config, async <T>(request: LlmJsonRequest) => {
assert.match(request.systemPrompt, /NOT a modified plank/i)
assert.match(request.userPrompt, /must_not_caricature/)
return {
title: 'Family formation without a creed',
summary: 'Outcomes and order, not theology.',
planks: ['Kids do better with two committed parents.'],
rationale: 'Sounds like that camp.',
warnings: [],
} as T
})
assert.equal(result.source, 'llm')
assert.equal(result.planks.length, 1)
})

it('falls back without an API key', async () => {
const bare: CauseAssistConfig = { ...config, apiKey: undefined }
const modified = await draftModifiedPlank({ parentPlanks: ['A.'], currentDraft: 'Keep me.' }, bare)
assert.equal(modified.source, 'fallback')
assert.equal(modified.plank, 'Keep me.')
const critique = await critiqueTriple({ modifiedPlanks: ['A.', 'B.'], bridgePlank: 'C.' }, bare)
assert.equal(critique.source, 'fallback')
assert.ok(critique.objections.length > 0)
const standIn = await draftStandInSliver({
sideLabel: 'secular conservatives',
currentDraft: { title: 'Keep title', planks: ['Keep plank.'] },
}, bare)
assert.equal(standIn.source, 'fallback')
assert.equal(standIn.title, 'Keep title')
assert.equal(standIn.planks[0], 'Keep plank.')
})
})
Loading
Loading