Moonlight 本機工具檢查副業是否與僱傭合約衝突
I Built a Local-First Tool to Check Whether Your Side Project Conflicts With Your Job
Arpan Patra 建立本機工具 Moonlight,掃描僱傭合約中可能影響副業的條款,引用原文並標示值得檢視的細節。在 14 條標註條款測試中,gemma3:4b 的分類結果為 7/14 (50%),引句逐字吻合為 14/14 (100%);作者因此移除模型分類,改用規則標記條款特徵。工具透過 Ollama 在本機執行;作者提醒它只供初步閲讀,不能代替法律意見,關鍵字檢索亦可能漏掉相關條款。
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle.
What I Built
I spent some time reading other entries in this week's challenge before starting my own. There were roughly a hundred projects, including receipt sorters, invoice extractors, Etsy listing writers, GST tools for small businesses, quote generators, and more. Many were well built and solved practical problems.
But most of them start from the same assumption: you've already launched your side hustle.
I kept thinking about the step before that. If you have a full-time job and want to build something on the side, one of the first questions is whether your employment agreement puts any limits on what you can do. The relevant clauses might cover outside work, intellectual property, exclusivity, or disclosure requirements. And many of us signed that agreement, skimmed it once, and haven't looked at it since.
That's why I built Moonlight. It scans an employment contract, finds clauses that may be relevant to a side project, quotes the original wording, and highlights specific details worth reviewing.
Can you sell what you build?
For example, Moonlight might find an intellectual-property clause that says:
"all Works conceived, created or developed by the Employee
during the term of employment, whether or not during working
hours, whether or not using the resources of the Company, and
whether or not relating to the present business of the
Company, shall be the sole and exclusive property of the
Company..."
It then highlights the wording that matters:
- Reaches beyond working hours: "whether or not during working hours"
- May apply to unrelated work: "whether or not relating to the present business of the Company"
- Covers work done on personal equipment: "whether or not using the resources of the Company"
- Transfers ownership: "hereby assigns to the Company all right"
It also suggests a question to investigate: Does this clause cover work created outside working hours?
Moonlight does not decide whether you're legally allowed to run a side business. It shows you what the agreement says, points to the exact language, and helps you identify what to ask about. That boundary is intentional, and it influenced several of the technical decisions I'll explain below.
The intended user is someone who wants to start a side project but isn't sure what their employment agreement says. Before worrying about invoices or sales, they need to understand what they signed.
Demo
Paste a contract and click Read it. On my laptop, a 28-clause agreement takes around 90 seconds to process, or roughly five seconds per clause.
Moonlight also shows a status line so you can see what happened during the review:
28 clauses in document · 12 matched on keywords · 12 quotes verified ·
0 readings discarded · 0 verdicts given · 88.3s · nothing left this laptop
There isn't a hosted demo, and that's a deliberate choice. Employment contracts can contain sensitive details about your salary, employer, and obligations. Sending them to a server just to get a first-pass review would defeat one of the main reasons I built this tool.
You can run it locally with npm install && npm start. A sample contract is included so you can try it without providing your own document.
Code
GitHub: imarpanpatra/moonlight MIT licensed.
The project uses Node.js, Express, Ollama, and gemma3:4b, with one application dependency.
ollama pull gemma3:4b
ollama serve
npm install && npm start # http://127.0.0.1:4455
npm run check # 56 tests
npm run airgap # checks that network traffic stays local
node src/eval.js # evaluates 14 labelled clauses
Here are the main files and what each one does:
| File | Responsibility |
|---|---|
src/clauses.js |
Splits the contract into clauses and retrieves candidates using keywords. No model involved. |
src/flags.js |
Detects specific clause properties and records the wording that triggered each flag. |
src/gemma.js |
The only file that opens a connection to the local model. The prompt lives here. |
src/review.js |
Coordinates the review, verifies quotes, strips verdicts, and orders the results. |
src/server.js |
Runs the Express server on 127.0.0.1. |
src/check.js |
Contains the 56 tests. |
src/eval.js |
Evaluates the model against 14 labelled clauses. |
src/airgap.js |
Instruments socket and DNS calls to check for non-local network access. |
How I Built It
Keep the model's job as small as possible
The first important decision was to avoid asking the model to read the entire contract and decide which clauses mattered.
Moonlight splits the document using clause numbering and carries section headings along with each clause. It then scores clauses against keyword sets for seven categories:
- Outside work
- Intellectual-property assignment
- Exclusivity
- Non-compete
- Non-solicitation
- Confidentiality
- Duty to disclose
A clause must contain an anchor phrase before it becomes a candidate. Without that requirement, generic wording such as "shall not" or "in writing" would match too many unrelated parts of a contract.
In my example, 28 clauses became 12 candidates. The model never sees the full agreement, and it doesn't decide which sections are relevant.
Extract the wording, then verify it
The model's job is to copy the operative sentence from a clause. Moonlight then checks whether the returned quote actually appears in the original text:
if (!normalise(clauseText).includes(normalise(quote))) {
// Not in the document. Discard the reading, keep the clause.
}
The check normalises whitespace before comparing the strings. If the quote isn't present, Moonlight treats it as an invalid reading rather than accepting it as a summary. The clause is still shown in its original form, labelled located, not read.
This matters because a paraphrase can sound convincing while changing the meaning of a legal clause. A person reviewing the output may not notice that an important qualification has disappeared. Checking the quote against the source gives the user a way to verify the evidence directly.
The evaluation changed the design
My first version asked gemma3:4b to classify each clause with a simple answer: does it apply outside the job yes, unclear, or no?
It answered unclear for all eight clauses I tested.
I revised the prompt, added clearer definitions, and included two worked examples. The same model then answered yes for all eight. The model and contract hadn't changed; only the wording of the prompt had.
That wasn't a reliable basis for a tool dealing with employment agreements, so I created a small evaluation set instead of continuing to tweak prompts by intuition. src/eval.js contains 14 labelled clauses: six that reach outside the job, five that do not, and three that are genuinely ambiguous.
Here's the result for gemma3:4b:
Model: gemma3:4b cases: 14
Classification: 7/14 (50%)
Quotes verbatim: 14/14 (100%)
Time: 72.4s total, 5.2s per clause
Confusion:
6 x yes -> yes
4 x no -> yes
3 x unclear -> yes
1 x no -> no
Binding clauses called harmless ("yes" -> "no"): 0
The results pointed in two different directions.
The model wasn't reliable at classification. It scored 50% and labelled 13 of the 14 clauses as yes. That could mean flagging something as ordinary as an annual-leave clause as relevant to side income.
It was reliable at copying the source text in this test. All 14 quotes matched the original wording. Across these examples, it didn't paraphrase or invent a sentence.
So I removed classification from the product and kept extraction. The replacement is src/flags.js, which uses pattern matching to identify specific properties and cites the phrase that triggered each one.
Examples include:
- Asks for permission rather than stating an outright ban: "without the prior written consent of the Company"
- Continues after employment ends: "at any time after its termination"
- Reaches beyond working hours: "whether or not during working hours"
- May cover unrelated work: "whether or not relating to the present business of the Company"
- Transfers ownership: "hereby assigns to the Company all right"
A flag can still be wrong, but its evidence is visible. You can inspect the exact phrase and decide whether the flag makes sense.
The permission distinction is especially important. A clause requiring written consent is not necessarily the same as a blanket ban. Treating those two situations as identical could discourage someone from pursuing a project they might otherwise be able to pursue after getting the required approval.
The evaluation also showed zero yes -> no errors: the model did not label a binding clause as harmless in this small test set. For this use case, missing a restrictive clause could be more damaging than asking someone to review an extra clause.
Why Open-Source AI Matters Here
The contract should stay on the user's machine
This is the main reason I chose a local model.
Uploading a client's contract to a hosted API can create a confidentiality risk. An employment agreement can reveal a person's salary, notice period, employer, and the exact obligations they're worried about. They may not want to send that information to a third party just to understand a clause.
For Moonlight, running an open-weight model locally isn't just a cost optimisation. It's part of the product's privacy design.
I wanted to verify that the application wasn't making external network connections, rather than simply claiming that it was private. src/airgap.js instruments net.Socket.prototype.connect, tls.connect, and dns.lookup before the app is imported. It runs a complete review and fails if it detects a connection to a non-loopback host. DNS lookups count too, because even a lookup can disclose information to a resolver.
Here's one run:
$ npm run airgap
Outbound connections observed: 7
7 x tcp 127.0.0.1:11434
DNS lookups observed: 0
PASS. Every connection was to loopback. The contract never left this machine.
This test checks the network activity observed during that run. It's a practical safeguard, not a claim that any software can be proven secure by one test alone.
Open weights made the failure visible
Because the model runs locally, I could try a smaller one by changing the configuration. gemma3:1b is around 815 MB, compared with 3.3 GB for gemma3:4b, and was 15% faster in my test. I ran it against the same evaluation set.
Model: gemma3:1b cases: 14
Classification: 6/14 (43%)
Quotes verbatim: 0/14 (0%)
The smaller model failed in two different ways.
On long clauses, it sometimes stopped following the expected output format. When asked for five keys, it returned only one:
{"governs_side_income":"yes"}
It generated eleven tokens and stopped, with done_reason: stop. Since there was no quote to verify, Moonlight labelled the clause located, not read and displayed the original text. The result was incomplete, but the app didn't present it as a successful reading.
On shorter clauses, it sometimes invented a quote by removing an important qualification. Given an outside-work clause, it produced:
"The Employee shall not engage in any other employment, consultancy, trade or business, whether or not for remuneration"
But the contract actually said:
"the Employee shall not, without the prior written consent of the Company, engage in any other employment, consultancy, trade or business"
The model omitted the consent requirement. Those words change how the clause reads: the first version sounds like an outright prohibition, while the original makes prior written consent part of the condition.
The output was fluent, but it wasn't faithful to the source. That is exactly the kind of mistake that could mislead someone reading a contract summary.
The quote-verification check caught it because the generated sentence wasn't present in the original text. I didn't have to invent a hypothetical failure to justify the check; it appeared in the first clause I tested with the smaller model.
That's one of the benefits of being able to run and evaluate the model yourself. I could switch models, run the same tests, and inspect the failure rather than relying only on a model card or benchmark. Open weights made the behaviour inspectable, and that directly influenced the design of Moonlight.
My Agent Session
The full build, including the experiments that changed the design, is available here:
If you're reviewing the session, the two useful moments are the evaluation that led me to remove classification and the smaller model's quote that dropped the consent requirement.
My First User
Moonlight is still an early project. I built it during the challenge window, and so far I've used it myself on one real contract that isn't included in the repository.
It surfaced three clauses I'd skimmed past. The flag about needing permission rather than facing an outright ban was the one that most changed how I understood the wording.
The sample contract included with the project is fictional and exists to exercise the code.
What Moonlight Is Not
Moonlight isn't a lawyer, and it doesn't claim to be one. It won't tell you whether a clause is allowed, enforceable, or advisable. If the model generates a verdict anyway, stripVerdicts() removes those statements. The 0 verdicts given status is a count of what the application did, not a marketing slogan.
The retrieval system is keyword-based, so a clause written in unexpected language can be missed. If Moonlight doesn't find something, that means its current keyword rules didn't identify it; it doesn't prove that the contract contains no relevant restrictions. The interface makes that limitation explicit.
Use it as a first-pass reading aid, verify the quoted text, and consult a qualified professional when you need legal advice.
Prize Category: Best Use of Gemma
Moonlight uses gemma3:4b through Ollama, running locally on a machine with a 4 GB RTX 3050. I also tested gemma3:1b against the same 14-clause evaluation set, and the results are included above.
The project is built around a simple idea: when a document is sensitive, the model should be able to run on the same machine that holds it.
Built during the Week 2 challenge window with Node.js, Express, Ollama, gemma3:4b, one application dependency, and an MIT license. No hosted inference API is required.
來源:DEV Community · dev.to
