PC Workman has a small offline assistant called hck_GPT.
It reads local sensor data, workload history, process information and hardware details. It answers in Polish or English. No account, no cloud, no machine data leaving the PC.
By v1.8.5, it had 96 intents, also had one file called builder.py.
That file was 6,533 lines long.
And it still worked.
That was the problem.
The file did not become ridiculous overnight
There was no single bad decision that created the monolith.
It grew ten lines at a time.
One new intent, one new response.
A Polish version beside the English one, a helper added near the bottom because I was already in the file.
A tester asks:
“Can it explain why my laptop is warm?”
So I add _resp_temperature().
Someone else asks:
“Can it tell me if I can run this game?”
So I add _resp_game_can_run().
Then startup questions.
Then RAM questions.
Then thermal history.
Then upgrade compatibility.
...
Every commit looked reasonable on its own.
That is how technical debt usually gets in.
Not through one obviously stupid decision.
Through a hundred small decisions that each save ten minutes.
Eventually ResponseBuilder had 96 handlers in one class:
class ResponseBuilder:
def _resp_temperature(self, result, lang):
...
def _resp_why_slow(self, result, lang):
...
def _resp_game_can_run(self, result, lang):
...
def _resp_full_specs(self, result, lang):
...
# 92 more
File opened slowly enough that my editor paused for a beat.
More importantly, I had stopped wandering through it.
I opened it only when I already knew exactly what I needed.
I avoided shared helpers because I could not remember what else they touched.
I postponed cleanup because even a small change meant scrolling past thousands of unrelated lines.
File was not impossible to understand.
It was just unpleasant enough that I stopped improving the parts around the task.
That was worse.
A file you are afraid to open is a file you have already stopped improving.
Why I finally touched it
I build PC Workman at night after working in a plastic-repair shop in Radom, Poland.
For a long time, that made the project feel private.
Messy code was my problem.
A slow page was my problem.
A version string copied into eight files was ugly, but nobody depended on it except me.
Then PC Workman reached the Microsoft Store.
Nothing changed in the repository that day.
Same files. Same functions.
Same 6,533-line builder.
But strangers could now click Install and run it on machines I would never see.
That changed what I was willing to ignore, project had users.
A tester offered a Ryzen 9 and RTX 4090 system as a test bench because I did not own hardware like that.
People started sending screenshots of PC Workman learning their machines.
Someone sent the first meaningful 50 PLN support payment.
These are small numbers by company standards.
For a solo project built after physical shifts, they changed the weight of the work.
Code no longer felt like a private mess I could clean “someday.”
So I opened builder.py and started cutting.
The one rule: do not break the public seam
Rest of the application talked to the response layer through one call:
response_builder.build(result, lang)
The chat panel used it, proactive monitor used it.
Test suite used it.
I could have redesigned the whole response API while splitting the file.
I did not.
I wanted one side of the refactor to stay completely still.
My rule was:
Move the implementation. Do not move the door.
That meant keeping:
-
ResponseBuilder; -
build(result, lang); -
_resp_<intent>handler names; - alias resolution;
- bilingual output;
- unsupported-intent behavior;
- every existing caller import.
A refactor that changes every caller at the same time is difficult to trust.
If something breaks, you no longer know whether the problem came from moving code or changing the contract.
So the contract stayed.
The facade stayed. The body moved out.
ResponseBuilder became a facade composed from domain mixins:
class ResponseBuilder(
HardwareResponses,
UpgradeResponses,
ThermalResponses,
GamingResponses,
SystemResponses,
PerformanceResponses,
InsightsResponses,
AssistantResponses,
):
"""Build bilingual responses for every parsed intent."""
The word “facade” sounds fancier than the idea, rest of the application still knocks on the same door.
Rooms behind it are just no longer one giant hall.
Each mixin contains one group of handlers:
class ThermalResponses:
def _resp_temperature(self, result, lang):
...
def _resp_fan_speed(self, result, lang):
...
def _resp_thermal_history(self, result, lang):
...
Mixins are not separate services.
They do not have their own lifecycle,they are slices of one object, separated because the methods change for different reasons.
That distinction mattered.
I was not trying to build an architecture diagram impressive enough for LinkedIn.
I was trying to stop one file from becoming the place where every future feature went to die.
The dispatcher did not move
The existing dispatch logic was already useful:
intent = self._INTENT_ALIASES.get(
result.intent,
result.intent,
)
handler = getattr(
self,
f"_resp_{intent}",
None,
)
if handler is None:
return None
return handler(result, lang)
For an intent such as:
temperature
the builder looks for:
_resp_temperature
getattr() performs that lookup on the final ResponseBuilder instance.
Python’s method resolution order walks through the mixins and finds the method.
It does not care whether _resp_temperature() lives inside the old builder.py or the new r_thermal.py.
The dispatch path stayed:
parsed intent
-> alias resolution
-> "_resp_<intent>"
-> method lookup
-> response
Filename was never part of the contract.
That made the extraction almost invisible from outside the package.
I split by subject, not by size
I did not create eight equal files.
Two equally large files can still be two badly organised files.
I grouped handlers by what they answer.
| Module | Handlers | Responsibility |
|---|---|---|
r_hardware.py |
25 | CPU, GPU, RAM, disks, motherboard, full specs |
r_insights.py |
15 | trends, comparisons, session digests, what changed |
r_performance.py |
13 | load, throttling, why the PC is slow |
r_system.py |
13 | health, processes, startup, security |
r_thermal.py |
11 | temperatures, fans, voltage baselines |
r_assistant.py |
11 | greetings, help, context recall |
r_gaming.py |
6 | game compatibility and gaming history |
r_upgrade.py |
2 | offline hardware compatibility |
Two more files carry shared work:
common.py
flows.py
common.py holds things such as:
- bilingual text selection;
- number formatting;
- follow-up hints;
- labels such as
+23% above your norm.
flows.py holds guided conversations that take more than one message.
The old builder.py dropped from 6,533 lines to under 600.
Its job is now boring:
- compose the mixins;
- resolve the intent;
- call the handler;
- return the answer.
That is exactly what I wanted.
The smallest module showed the biggest change
r_upgrade.py started with only two handlers.
It could easily have been pasted into an existing file.
A year earlier, that is what I would have done.
New feature would have landed wherever the cursor already was.
This time it received its own module from the first line.
Two handlers, lots of empty room.
That may sound trivial, but it is the cleanest proof that the refactor changed more than file sizes.
Next feature had somewhere sensible to live.
Mixins solved one problem and introduced another
Mixins were useful here, but they have a sharp edge.
Two mixins can define the same method:
class ThermalResponses:
def _resp_health_check(self, result, lang):
...
class SystemResponses:
def _resp_health_check(self, result, lang):
...
Python does not complain.
One method wins according to inheritance order.
The other remains in the repository, looks correct during review and never runs.
That is exactly the kind of bug I do not want in a response system.
Nothing crashes.
The answer simply comes from the wrong handler.
So I added a test that scans every response module and rejects duplicate _resp_* names:
all_defs = []
for path in glob.glob(_RESPONSES_GLOB):
source = _read(path)
all_defs += re.findall(
r"def (_resp_[a-z0-9_]+)\(",
source,
)
duplicates = {
name
for name in all_defs
if all_defs.count(name) > 1
}
self.assertEqual(duplicates, set())
It is not clever, it does not need to be.
Handler naming convention is strict, so a simple structural test is enough.
Important part is that silent MRO shadowing now turns the build red.
I also added a test against my future laziness
Splitting the monolith once was not enough.
Pressure that created it still exists.
At 11 PM, adding a handler to an existing module will always feel faster than deciding whether the module should split.
A README note would not stop me.
So every response module now has a line limit:
def test_monolith_guard(self):
for path in glob.glob(_RESPONSES_GLOB):
line_count = _read(path).count("\n") + 1
self.assertLess(
line_count,
1600,
(
f"{os.path.basename(path)} has "
f"{line_count} lines - split it "
"instead of growing another monolith"
),
)
The number 1600 is not a universal clean-code law.
A 1,601-line file is not automatically terrible.
A 300-line file can still be nonsense.
This is a project-specific ratchet.
It says:
No response domain is allowed to become the next 6,533-line dumping ground.
The failure message is deliberately plain.
Not:
Architecture policy violation
But:
split it instead of growing another monolith
That message is for me six months from now.
A refactor without a ratchet is just a delay.
Before and after
Before
hck_gpt/
└── responses/
└── builder.py # 6,533 lines
After
hck_gpt/
└── responses/
├── builder.py # facade + dispatch, under 600 lines
├── common.py
├── flows.py
├── r_assistant.py
├── r_gaming.py
├── r_hardware.py
├── r_insights.py
├── r_performance.py
├── r_system.py
├── r_thermal.py
└── r_upgrade.py
Public call stayed the same:
response_builder.build(result, lang)
Maintenance experience did not.
Now:
- thermal responses live with thermal responses;
- hardware work does not require scrolling past greetings;
- duplicate handlers fail CI;
- oversized modules fail CI;
- new domains have an obvious starting point;
- callers remain untouched.
The same release fixed a two-second UI delay
The builder was the biggest refactor in v1.8.5, but not the only one.
The My PC page used to take about two seconds to reopen.
I blamed the charts first.
Then the amount of hardware data.
The actual reason was much less glamorous:
two wmic calls on the UI thread
One fetched the CPU name and one fetched the disk model. Each could wait up to three seconds.
On current Windows systems, wmic can be painfully slow.
The page was paying that cost during an interactive action.
Fix was simple once the cause was visible:
- Collect stable hardware identity once during startup.
- Reuse the cached identity.
- Build the page once.
- Keep it alive instead of destroying and rebuilding it.
The page now reopens in roughly 1–17 ms.
Lesson was not “never use wmic.”
It was:
Stable hardware identity does not belong on the path that must feel instant.
The version number lived in eight places
The app version had another quiet failure, it existed in eight files they had already drifted.
Title bar could report one version while the single-instance logic searched for a different window title.
A second launch would then fail to focus the copy already running.
Nothing dramatic happened.
The app just behaved slightly wrong.
Version now has one source of truth.
A test scans for hardcoded duplicates and fails if the number starts spreading again.
This pattern became common across the release:
bug
-> root cause
-> fix
-> regression test
-> build guard
Fixing the current problem is useful.
Making the same class of problem harder to reintroduce is better.
Test suite went from 21 to 194
In June, PC Workman had 21 tests.
By v1.8.5, it had 194.
Number is not the interesting part, what matters is where the tests came from.
Many were written after failures:
- intent patterns with no response handler;
- Store installs writing into a read-only path;
- protected Windows processes being treated like normal apps;
- anti-cheat processes missing from safety rules;
- background daemons starting twice;
- learning engines receiving no usable sensor data;
- version strings drifting;
- response modules growing without a boundary;
- duplicate handlers hiding behind mixin order.
That is the closest a solo project gets to institutional memory.
There is no team meeting where somebody says:
"Remember the bug from three months ago?"
Test says it every time the code changes.
What I deliberately did not do
I did not rename everything
Moving and renaming at the same time makes failures harder to trace.
I moved first. Names can improve later.
I did not replace the public API
A new registry or explicit service composition might be cleaner eventually.
That was not job of this refactor.
Job was to reduce maintenance risk without creating a second project.
I did not chase equal file sizes
Line count is a guard, not a design method.
Real boundary is responsibility.
I did not pretend mixins are perfect
They preserve shared state and the current public object.
They also make inheritance order matter, that is why the collision test exists.
I did not try to remove every piece of technical debt
That would have turned one safe extraction into an endless rewrite.
Codebase only needed to become safer for the next change and that was enough.
When this pattern makes sense
A facade plus domain mixins can be a reasonable bridge when:
- callers already depend on one stable object;
- many handlers share the same state;
- methods follow a strict naming convention;
- preserving the public surface matters;
- the immediate problem is ownership and navigation;
- tests can guard name collisions and module growth.
It is a poor fit when:
- each domain needs separate mutable state;
- components have independent lifecycles;
- dependency boundaries must be explicit;
- method names overlap naturally;
- inheritance order becomes business logic;
- separate services can be introduced without breaking callers.
This is not "the correct Python architecture.", but smallest safe move from one bad state to a better one.
What actually changed
From the user’s point of view, almost nothing.
They still ask a question.
Parser still produces an intent, builder still calls:
_resp_<intent>
The same public method still returns the answer.
From my point of view, codebase changed completely.
File I avoided is gone.
The new upgrade feature has its own home, UI no longer waits on slow hardware calls, version number stopped disagreeing with itself.
And the build now refuses several mistakes that used to survive quietly.
That is the part of refactoring people rarely screenshot, Not smaller file. The fear disappearing from the next change.
PC Workman is free, open-source and offline by design.
The response-builder split, the architectural guards, the My PC speed-up and the expanded test suite ship in v1.8.5.
- Browse the source on GitHub
- Install PC Workman from the Microsoft Store
- Read the project story and technical guides
❤️ If you want to support me, click here :) ❤️
I am Marcin “HCK” Firmuga from Radom, Poland. I repair and weld plastic during the day, then build PC Workman at night. I write about the code that shipped, the assumptions that failed, and the fixes that now have tests behind them.


Top comments (0)